Skip to content

Commit d147cad

Browse files
committed
fixup finish part provider metadata
1 parent 6303395 commit d147cad

File tree

8 files changed

+100
-33
lines changed

8 files changed

+100
-33
lines changed

.changeset/five-colts-eat.md

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,31 @@ const main = Effect.gen(function*() {
4848

4949
The `AiInput` and `AiResponse` types have been refactored to allow inclusion of more information and metadata from model providers where possible, such as reasoning output and prompt cache token utilization.
5050

51+
In addition, for an `AiResponse` you can now access metadata that is specific to a given provider. For example, when using OpenAi to generate audio, you can check the input and output audio tokens used:
52+
53+
```ts
54+
import { OpenAiLanguageModel } from "@effect/ai-openai"
55+
import { Effect, Option } from "effect"
56+
57+
const getDadJoke = OpenAiLanguageModel.generateText({
58+
prompt: "Generate a hilarious dad joke"
59+
})
60+
61+
Effect.gen(function*() {
62+
const model = yield* OpenAiLanguageModel.model("gpt-4o")
63+
const response = yield* model.use(getDadJoke)
64+
const metadata = response.getProviderMetadata(OpenAiLanguageModel.ProviderMetadata)
65+
if (Option.isSome(metadata)) {
66+
console.log(metadata.value)
67+
}
68+
})
69+
70+
```
71+
5172
### `AiTool` and `AiToolkit`
5273

5374
The `AiToolkit` has been completely refactored to simplify creating a collection of tools and using those tools in requests to model providers. A new `AiTool` data type has also been introduced to simplify defining tools for a toolkit. `AiToolkit.implement` has been renamed to `AiToolkit.toLayer` for clarity, and defining handlers is now very similar to the way handlers are defined in the `@effect/rpc` library.
5475

55-
In addition, you can now control how many sequential steps are performed by `AiLanguageModel.generateText` and `AiLanguageModel.streamText` via the `maxSteps` option. For example, if `maxSteps` is set to `> 1` and any tools are invoked by the language model, these methods will take care of resolving the tool call and returning the results to the language model for subsequent generation (up to the maximum number of steps specified).
56-
5776
A complete example of an `AiToolkit` implementation and usage can be found below:
5877

5978

@@ -156,10 +175,7 @@ const makeDadJoke = Effect.gen(function*() {
156175

157176
const response = yield* languageModel.generateText({
158177
prompt: "Come up with a dad joke about pirates",
159-
toolkit,
160-
// Allow a maximum of two sequential interactions with the language model
161-
// before returning the response
162-
maxSteps: 2
178+
toolkit
163179
})
164180

165181
return yield* languageModel.generateText({

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"clean": "node scripts/clean.mjs",
77
"codegen": "pnpm --recursive --parallel --filter \"./packages/**/*\" run codegen",
88
"codemod": "node scripts/codemod.mjs",
9-
"build": "tsc -b tsconfig.build.json && pnpm --recursive --parallel run build",
9+
"build": "tsc -b tsconfig.build.json && pnpm --recursive --parallel --filter \"./packages/**/*\" run build",
1010
"circular": "node scripts/circular.mjs",
1111
"test": "vitest",
1212
"coverage": "vitest --coverage",

packages/ai/anthropic/src/AnthropicClient.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import * as Redacted from "effect/Redacted"
2121
import * as Stream from "effect/Stream"
2222
import { AnthropicConfig } from "./AnthropicConfig.js"
2323
import * as Generated from "./Generated.js"
24-
import { resolveFinishReason } from "./internal/utilities.js"
24+
import * as InternalUtilities from "./internal/utilities.js"
2525

2626
const constDisableValidation = { disableValidation: true } as const
2727

@@ -124,6 +124,7 @@ export const make = (options: {
124124
cacheReadInputTokens: 0,
125125
cacheWriteInputTokens: 0
126126
}
127+
const metadata: Record<string, unknown> = {}
127128
return streamRequest<MessageStreamEvent>(
128129
HttpClientRequest.post("/v1/messages", {
129130
body: HttpBody.unsafeJson({ ...request, stream: true })
@@ -158,18 +159,19 @@ export const make = (options: {
158159
...usage,
159160
outputTokens: chunk.usage.output_tokens
160161
}
161-
finishReason = resolveFinishReason(chunk.delta.stop_reason)
162+
if (Predicate.isNotNullable(chunk.delta.stop_sequence)) {
163+
metadata.stopSequence = chunk.delta.stop_sequence
164+
}
165+
finishReason = InternalUtilities.resolveFinishReason(chunk.delta.stop_reason)
162166
break
163167
}
164168
case "message_stop": {
165169
parts.push(
166-
new AiResponse.FinishPart(
167-
{
168-
reason: finishReason,
169-
usage
170-
},
171-
constDisableValidation
172-
)
170+
new AiResponse.FinishPart({
171+
usage,
172+
reason: finishReason,
173+
providerMetadata: { [InternalUtilities.ProviderMetadataKey]: metadata }
174+
}, constDisableValidation)
173175
)
174176
break
175177
}

packages/ai/anthropic/src/AnthropicLanguageModel.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import type { Mutable, Simplify } from "effect/Types"
2121
import { AnthropicClient } from "./AnthropicClient.js"
2222
import * as AnthropicTokenizer from "./AnthropicTokenizer.js"
2323
import type * as Generated from "./Generated.js"
24-
import { resolveFinishReason } from "./internal/utilities.js"
24+
import * as InternalUtilities from "./internal/utilities.js"
2525

2626
const constDisableValidation = { disableValidation: true } as const
2727

@@ -80,7 +80,7 @@ export declare namespace Config {
8080
* @since 1.0.0
8181
* @category Context
8282
*/
83-
export class ProviderMetadata extends Context.Tag("@effect/ai-anthropic/AnthropicLanguageModel/ProviderMetadata")<
83+
export class ProviderMetadata extends Context.Tag(InternalUtilities.ProviderMetadataKey)<
8484
ProviderMetadata,
8585
ProviderMetadata.Service
8686
>() {}
@@ -94,6 +94,12 @@ export declare namespace ProviderMetadata {
9494
* @category Provider Metadata
9595
*/
9696
export interface Service {
97+
/**
98+
* Which custom stop sequence was generated, if any.
99+
*
100+
* Will be a non-null string if one of your custom stop sequences was
101+
* generated.
102+
*/
97103
readonly stopSequence?: string
98104
}
99105
}
@@ -496,7 +502,7 @@ const makeResponse = Effect.fnUntraced(
496502
parts.push(
497503
new AiResponse.FinishPart({
498504
// Anthropic always returns a non-null `stop_reason` for non-streaming responses
499-
reason: resolveFinishReason(response.stop_reason!),
505+
reason: InternalUtilities.resolveFinishReason(response.stop_reason!),
500506
usage: new AiResponse.Usage({
501507
inputTokens: response.usage.input_tokens,
502508
outputTokens: response.usage.output_tokens,
@@ -505,9 +511,7 @@ const makeResponse = Effect.fnUntraced(
505511
cacheReadInputTokens: response.usage.cache_read_input_tokens ?? 0,
506512
cacheWriteInputTokens: response.usage.cache_creation_input_tokens ?? 0
507513
}),
508-
providerMetadata: {
509-
[ProviderMetadata.key]: metadata
510-
}
514+
providerMetadata: { [InternalUtilities.ProviderMetadataKey]: metadata }
511515
}, constDisableValidation)
512516
)
513517
return new AiResponse.AiResponse({

packages/ai/anthropic/src/internal/utilities.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type * as AiResponse from "@effect/ai/AiResponse"
22
import * as Predicate from "effect/Predicate"
33

4+
/** @internal */
5+
export const ProviderMetadataKey = "@effect/ai-anthropic/AnthropicLanguageModel/ProviderMetadata"
6+
47
const finishReasonMap: Record<string, AiResponse.FinishReason> = {
58
end_turn: "stop",
69
max_tokens: "length",

packages/ai/openai/src/OpenAiClient.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import * as Predicate from "effect/Predicate"
1919
import * as Redacted from "effect/Redacted"
2020
import * as Stream from "effect/Stream"
2121
import * as Generated from "./Generated.js"
22-
import { resolveFinishReason } from "./internal/utilities.js"
22+
import * as InternalUtilities from "./internal/utilities.js"
2323
import { OpenAiConfig } from "./OpenAiConfig.js"
2424

2525
const constDisableValidation = { disableValidation: true } as const
@@ -167,10 +167,19 @@ export const make = (options: {
167167

168168
// Track the finish reason for the response
169169
if (Predicate.isNotNullable(choice.finish_reason)) {
170-
finishReason = resolveFinishReason(choice.finish_reason)
170+
finishReason = InternalUtilities.resolveFinishReason(choice.finish_reason)
171171
if (finishReason === "tool-calls" && Predicate.isNotUndefined(toolCallIndex)) {
172172
finishToolCall(toolCalls[toolCallIndex], parts)
173173
}
174+
if (finishReason === "stop") {
175+
parts.push(
176+
new AiResponse.FinishPart({
177+
usage,
178+
reason: finishReason,
179+
providerMetadata: { [InternalUtilities.ProviderMetadataKey]: metadata }
180+
}, constDisableValidation)
181+
)
182+
}
174183
}
175184

176185
// Handle text deltas

packages/ai/openai/src/OpenAiLanguageModel.ts

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type { Span } from "effect/Tracer"
1919
import type { Simplify } from "effect/Types"
2020
import type * as Generated from "./Generated.js"
2121
import { resolveFinishReason } from "./internal/utilities.js"
22+
import * as InternalUtilities from "./internal/utilities.js"
2223
import { OpenAiClient } from "./OpenAiClient.js"
2324
import { addGenAIAnnotations } from "./OpenAiTelemetry.js"
2425
import * as OpenAiTokenizer from "./OpenAiTokenizer.js"
@@ -80,7 +81,7 @@ export declare namespace Config {
8081
* @since 1.0.0
8182
* @category Context
8283
*/
83-
export class ProviderMetadata extends Context.Tag("@effect/ai-openai/OpenAiLanguageModel/ProviderMetadata")<
84+
export class ProviderMetadata extends Context.Tag(InternalUtilities.ProviderMetadataKey)<
8485
ProviderMetadata,
8586
ProviderMetadata.Service
8687
>() {}
@@ -94,7 +95,38 @@ export declare namespace ProviderMetadata {
9495
* @category Provider Metadata
9596
*/
9697
export interface Service {
97-
readonly stopSequence?: string
98+
/**
99+
* Specifies the latency tier that was used for processing the request.
100+
*/
101+
readonly serviceTier?: string
102+
/**
103+
* This fingerprint represents the backend configuration that the model
104+
* executes with.
105+
*
106+
* Can be used in conjunction with the seed request parameter to understand
107+
* when backend changes have been made that might impact determinism.
108+
*/
109+
readonly systemFingerprint: string
110+
/**
111+
* When using predicted outputs, the number of tokens in the prediction
112+
* that appeared in the completion.
113+
*/
114+
readonly acceptedPredictionTokens: number
115+
/**
116+
* When using predicted outputs, the number of tokens in the prediction
117+
* that did not appear in the completion. However, like reasoning tokens,
118+
* these tokens are still counted in the total completion tokens for
119+
* purposes of billing, output, and context window limits.
120+
*/
121+
readonly rejectedPredictionTokens: number
122+
/**
123+
* Audio tokens present in the prompt.
124+
*/
125+
readonly inputAudioTokens: number
126+
/**
127+
* Audio tokens generated by the model.
128+
*/
129+
readonly outputAudioTokens: number
98130
}
99131
}
100132

@@ -425,16 +457,16 @@ const makeResponse = Effect.fnUntraced(function*(
425457
metadata.systemFingerprint = response.system_fingerprint
426458
}
427459
if (Predicate.isNotUndefined(response.usage?.completion_tokens_details?.accepted_prediction_tokens)) {
428-
metadata.acceptedPredictionTokens = response.usage?.completion_tokens_details?.accepted_prediction_tokens
460+
metadata.acceptedPredictionTokens = response.usage?.completion_tokens_details?.accepted_prediction_tokens ?? 0
429461
}
430462
if (Predicate.isNotUndefined(response.usage?.completion_tokens_details?.rejected_prediction_tokens)) {
431-
metadata.rejectedPredictionTokens = response.usage?.completion_tokens_details?.rejected_prediction_tokens
463+
metadata.rejectedPredictionTokens = response.usage?.completion_tokens_details?.rejected_prediction_tokens ?? 0
432464
}
433465
if (Predicate.isNotUndefined(response.usage?.prompt_tokens_details?.audio_tokens)) {
434-
metadata.inputAudioTokens = response.usage?.prompt_tokens_details?.audio_tokens
466+
metadata.inputAudioTokens = response.usage?.prompt_tokens_details?.audio_tokens ?? 0
435467
}
436468
if (Predicate.isNotUndefined(response.usage?.completion_tokens_details?.audio_tokens)) {
437-
metadata.outputAudioTokens = response.usage?.completion_tokens_details?.audio_tokens
469+
metadata.outputAudioTokens = response.usage?.completion_tokens_details?.audio_tokens ?? 0
438470
}
439471
parts.push(
440472
new AiResponse.FinishPart({
@@ -447,9 +479,7 @@ const makeResponse = Effect.fnUntraced(function*(
447479
cacheReadInputTokens: response.usage?.prompt_tokens_details?.cached_tokens ?? 0,
448480
cacheWriteInputTokens: 0
449481
}, constDisableValidation),
450-
providerMetadata: {
451-
[ProviderMetadata.key]: metadata
452-
}
482+
providerMetadata: { [InternalUtilities.ProviderMetadataKey]: metadata }
453483
}, constDisableValidation)
454484
)
455485
if (Predicate.isNotNullable(choice.message.content)) {

packages/ai/openai/src/internal/utilities.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type * as AiResponse from "@effect/ai/AiResponse"
22
import * as Predicate from "effect/Predicate"
33

4+
/** @internal */
5+
export const ProviderMetadataKey = "@effect/ai-openai/OpenAiLanguageModel/ProviderMetadata"
6+
47
const finishReasonMap: Record<string, AiResponse.FinishReason> = {
58
content_filter: "content-filter",
69
function_call: "tool-calls",

0 commit comments

Comments
 (0)