@codilore/llm 1.15.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +321 -0
- package/README.md +131 -0
- package/example/call-sites.md +591 -0
- package/example/tutorial.ts +255 -0
- package/package.json +50 -0
- package/script/recording-cost-report.ts +250 -0
- package/script/setup-recording-env.ts +542 -0
- package/src/cache-policy.ts +111 -0
- package/src/index.ts +32 -0
- package/src/llm.ts +186 -0
- package/src/protocols/anthropic-messages.ts +841 -0
- package/src/protocols/bedrock-converse.ts +649 -0
- package/src/protocols/bedrock-event-stream.ts +87 -0
- package/src/protocols/gemini.ts +465 -0
- package/src/protocols/index.ts +6 -0
- package/src/protocols/openai-chat.ts +431 -0
- package/src/protocols/openai-compatible-chat.ts +24 -0
- package/src/protocols/openai-responses.ts +987 -0
- package/src/protocols/shared.ts +283 -0
- package/src/protocols/utils/bedrock-auth.ts +70 -0
- package/src/protocols/utils/bedrock-cache.ts +37 -0
- package/src/protocols/utils/bedrock-media.ts +80 -0
- package/src/protocols/utils/cache.ts +16 -0
- package/src/protocols/utils/gemini-tool-schema.ts +101 -0
- package/src/protocols/utils/lifecycle.ts +102 -0
- package/src/protocols/utils/openai-options.ts +84 -0
- package/src/protocols/utils/tool-stream.ts +218 -0
- package/src/provider.ts +37 -0
- package/src/providers/amazon-bedrock.ts +43 -0
- package/src/providers/anthropic.ts +35 -0
- package/src/providers/azure.ts +110 -0
- package/src/providers/cloudflare.ts +127 -0
- package/src/providers/github-copilot.ts +66 -0
- package/src/providers/google.ts +35 -0
- package/src/providers/index.ts +11 -0
- package/src/providers/openai-compatible-profile.ts +20 -0
- package/src/providers/openai-compatible.ts +65 -0
- package/src/providers/openai-options.ts +81 -0
- package/src/providers/openai.ts +63 -0
- package/src/providers/openrouter.ts +98 -0
- package/src/providers/xai.ts +56 -0
- package/src/route/auth-options.ts +57 -0
- package/src/route/auth.ts +156 -0
- package/src/route/client.ts +434 -0
- package/src/route/endpoint.ts +53 -0
- package/src/route/executor.ts +374 -0
- package/src/route/framing.ts +27 -0
- package/src/route/index.ts +25 -0
- package/src/route/protocol.ts +84 -0
- package/src/route/transport/http.ts +108 -0
- package/src/route/transport/index.ts +33 -0
- package/src/route/transport/websocket.ts +280 -0
- package/src/schema/errors.ts +203 -0
- package/src/schema/events.ts +370 -0
- package/src/schema/ids.ts +43 -0
- package/src/schema/index.ts +5 -0
- package/src/schema/messages.ts +404 -0
- package/src/schema/options.ts +221 -0
- package/src/tool-runtime.ts +78 -0
- package/src/tool.ts +241 -0
- package/src/utils/record.ts +3 -0
- package/sst-env.d.ts +10 -0
- package/test/adapter.test.ts +164 -0
- package/test/auth-options.types.ts +168 -0
- package/test/auth.test.ts +103 -0
- package/test/cache-policy.test.ts +262 -0
- package/test/continuation-scenarios.ts +104 -0
- package/test/endpoint.test.ts +58 -0
- package/test/executor.test.ts +418 -0
- package/test/exports.test.ts +62 -0
- package/test/fixtures/media/restroom.png +0 -0
- package/test/fixtures/recordings/anthropic-messages/accepts-malformed-assistant-tool-order-with-default-patch.json +29 -0
- package/test/fixtures/recordings/anthropic-messages/anthropic-opus-4-7-image-tool-result.json +43 -0
- package/test/fixtures/recordings/anthropic-messages/claude-opus-4-7-drives-a-tool-loop.json +56 -0
- package/test/fixtures/recordings/anthropic-messages/rejects-malformed-assistant-tool-order-without-patch.json +29 -0
- package/test/fixtures/recordings/anthropic-messages/streams-text.json +29 -0
- package/test/fixtures/recordings/anthropic-messages/streams-tool-call.json +29 -0
- package/test/fixtures/recordings/anthropic-messages-cache/writes-then-reads-cache-control-on-identical-second-call.json +48 -0
- package/test/fixtures/recordings/bedrock-converse/drives-a-tool-loop.json +55 -0
- package/test/fixtures/recordings/bedrock-converse/streams-a-tool-call.json +29 -0
- package/test/fixtures/recordings/bedrock-converse/streams-text.json +29 -0
- package/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-gpt-oss-20b-tools-tool-call.json +32 -0
- package/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-llama-3-1-8b-text.json +32 -0
- package/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-gpt-oss-20b-tools-tool-call.json +32 -0
- package/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-llama-3-1-8b-text.json +32 -0
- package/test/fixtures/recordings/gemini/gemini-2-5-flash-image.json +32 -0
- package/test/fixtures/recordings/gemini/streams-text.json +28 -0
- package/test/fixtures/recordings/gemini/streams-tool-call.json +28 -0
- package/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json +46 -0
- package/test/fixtures/recordings/openai-chat/continues-after-tool-result.json +28 -0
- package/test/fixtures/recordings/openai-chat/drives-a-tool-loop-end-to-end.json +46 -0
- package/test/fixtures/recordings/openai-chat/streams-text.json +28 -0
- package/test/fixtures/recordings/openai-chat/streams-tool-call.json +28 -0
- package/test/fixtures/recordings/openai-compatible-chat/deepseek-streams-text.json +28 -0
- package/test/fixtures/recordings/openai-compatible-chat/groq-llama-3-3-70b-drives-a-tool-loop.json +53 -0
- package/test/fixtures/recordings/openai-compatible-chat/groq-streams-text.json +28 -0
- package/test/fixtures/recordings/openai-compatible-chat/groq-streams-tool-call.json +28 -0
- package/test/fixtures/recordings/openai-compatible-chat/openrouter-claude-opus-4-7-drives-a-tool-loop.json +54 -0
- package/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-4o-mini-drives-a-tool-loop.json +53 -0
- package/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-5-5-drives-a-tool-loop.json +54 -0
- package/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-text.json +28 -0
- package/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-tool-call.json +28 -0
- package/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-text.json +28 -0
- package/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-tool-call.json +28 -0
- package/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json +54 -0
- package/test/fixtures/recordings/openai-responses/gpt-5-5-streams-text.json +28 -0
- package/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json +28 -0
- package/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json +42 -0
- package/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json +58 -0
- package/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning.json +32 -0
- package/test/fixtures/recordings/openai-responses-cache/reports-cached-tokens-on-identical-second-call.json +46 -0
- package/test/generate-object.test.ts +184 -0
- package/test/lib/effect.ts +50 -0
- package/test/lib/http.ts +98 -0
- package/test/lib/openai-chunks.ts +27 -0
- package/test/lib/sse.ts +17 -0
- package/test/lib/tool-runtime.ts +146 -0
- package/test/llm.test.ts +167 -0
- package/test/provider/anthropic-messages-cache.recorded.test.ts +54 -0
- package/test/provider/anthropic-messages.recorded.test.ts +46 -0
- package/test/provider/anthropic-messages.test.ts +829 -0
- package/test/provider/bedrock-converse-cache.recorded.test.ts +54 -0
- package/test/provider/bedrock-converse.test.ts +707 -0
- package/test/provider/cloudflare.test.ts +230 -0
- package/test/provider/gemini-cache.recorded.test.ts +48 -0
- package/test/provider/gemini.test.ts +476 -0
- package/test/provider/golden.recorded.test.ts +219 -0
- package/test/provider/openai-chat.test.ts +446 -0
- package/test/provider/openai-compatible-chat.test.ts +238 -0
- package/test/provider/openai-responses-cache.recorded.test.ts +46 -0
- package/test/provider/openai-responses.test.ts +1322 -0
- package/test/provider/openrouter.test.ts +56 -0
- package/test/provider.types.ts +41 -0
- package/test/recorded-golden.ts +97 -0
- package/test/recorded-runner.ts +100 -0
- package/test/recorded-scenarios.ts +531 -0
- package/test/recorded-test.ts +74 -0
- package/test/recorded-utils.ts +56 -0
- package/test/recorded-websocket.ts +26 -0
- package/test/route.test.ts +43 -0
- package/test/schema.test.ts +97 -0
- package/test/tool-runtime.test.ts +802 -0
- package/test/tool-stream.test.ts +99 -0
- package/test/tool.types.ts +40 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { LLMEvent, type FinishReason, type ProviderMetadata, type Usage } from "../../schema"
|
|
2
|
+
|
|
3
|
+
export interface State {
|
|
4
|
+
readonly stepStarted: boolean
|
|
5
|
+
readonly text: ReadonlySet<string>
|
|
6
|
+
readonly reasoning: ReadonlySet<string>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const initial = (): State => ({ stepStarted: false, text: new Set(), reasoning: new Set() })
|
|
10
|
+
|
|
11
|
+
export const stepStart = (state: State, events: LLMEvent[]): State => {
|
|
12
|
+
if (state.stepStarted) return state
|
|
13
|
+
events.push(LLMEvent.stepStart({ index: 0 }))
|
|
14
|
+
return { ...state, stepStarted: true }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
|
|
18
|
+
const stepped = stepStart(state, events)
|
|
19
|
+
if (stepped.text.has(id)) {
|
|
20
|
+
events.push(LLMEvent.textDelta({ id, text }))
|
|
21
|
+
return stepped
|
|
22
|
+
}
|
|
23
|
+
events.push(LLMEvent.textStart({ id }), LLMEvent.textDelta({ id, text }))
|
|
24
|
+
return { ...stepped, text: new Set([...stepped.text, id]) }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const reasoningStart = (
|
|
28
|
+
state: State,
|
|
29
|
+
events: LLMEvent[],
|
|
30
|
+
id: string,
|
|
31
|
+
providerMetadata?: ProviderMetadata,
|
|
32
|
+
): State => {
|
|
33
|
+
if (state.reasoning.has(id)) return state
|
|
34
|
+
const stepped = stepStart(state, events)
|
|
35
|
+
events.push(LLMEvent.reasoningStart({ id, providerMetadata }))
|
|
36
|
+
return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const reasoningDelta = (
|
|
40
|
+
state: State,
|
|
41
|
+
events: LLMEvent[],
|
|
42
|
+
id: string,
|
|
43
|
+
text: string,
|
|
44
|
+
providerMetadata?: ProviderMetadata,
|
|
45
|
+
): State => {
|
|
46
|
+
const started = reasoningStart(state, events, id, providerMetadata)
|
|
47
|
+
events.push(LLMEvent.reasoningDelta({ id, text }))
|
|
48
|
+
return started
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const reasoningEnd = (
|
|
52
|
+
state: State,
|
|
53
|
+
events: LLMEvent[],
|
|
54
|
+
id: string,
|
|
55
|
+
providerMetadata?: ProviderMetadata,
|
|
56
|
+
): State => {
|
|
57
|
+
if (!state.reasoning.has(id)) return state
|
|
58
|
+
const stepped = stepStart(state, events)
|
|
59
|
+
events.push(LLMEvent.reasoningEnd({ id, providerMetadata }))
|
|
60
|
+
const reasoning = new Set(stepped.reasoning)
|
|
61
|
+
reasoning.delete(id)
|
|
62
|
+
return { ...stepped, reasoning }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
|
|
66
|
+
if (!state.text.has(id)) return state
|
|
67
|
+
const stepped = stepStart(state, events)
|
|
68
|
+
events.push(LLMEvent.textEnd({ id, providerMetadata }))
|
|
69
|
+
const text = new Set(stepped.text)
|
|
70
|
+
text.delete(id)
|
|
71
|
+
return { ...stepped, text }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const closeOpenBlocks = (state: State, events: LLMEvent[]): State => {
|
|
75
|
+
for (const id of state.reasoning) events.push(LLMEvent.reasoningEnd({ id }))
|
|
76
|
+
for (const id of state.text) events.push(LLMEvent.textEnd({ id }))
|
|
77
|
+
return { ...state, text: new Set(), reasoning: new Set() }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const finish = (
|
|
81
|
+
state: State,
|
|
82
|
+
events: LLMEvent[],
|
|
83
|
+
input: {
|
|
84
|
+
readonly reason: FinishReason
|
|
85
|
+
readonly usage?: Usage
|
|
86
|
+
readonly providerMetadata?: ProviderMetadata
|
|
87
|
+
},
|
|
88
|
+
): State => {
|
|
89
|
+
const stepped = closeOpenBlocks(stepStart(state, events), events)
|
|
90
|
+
events.push(
|
|
91
|
+
LLMEvent.stepFinish({
|
|
92
|
+
index: 0,
|
|
93
|
+
reason: input.reason,
|
|
94
|
+
usage: input.usage,
|
|
95
|
+
providerMetadata: input.providerMetadata,
|
|
96
|
+
}),
|
|
97
|
+
LLMEvent.finish(input),
|
|
98
|
+
)
|
|
99
|
+
return { ...stepped, stepStarted: false }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export * as Lifecycle from "./lifecycle"
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Schema } from "effect"
|
|
2
|
+
import type { LLMRequest, ReasoningEffort, TextVerbosity as TextVerbosityValue } from "../../schema"
|
|
3
|
+
import { ReasoningEfforts, TextVerbosity } from "../../schema"
|
|
4
|
+
|
|
5
|
+
export const OpenAIReasoningEfforts = ReasoningEfforts.filter(
|
|
6
|
+
(effort): effort is Exclude<ReasoningEffort, "max"> => effort !== "max",
|
|
7
|
+
)
|
|
8
|
+
export type OpenAIReasoningEffort = (typeof OpenAIReasoningEfforts)[number]
|
|
9
|
+
|
|
10
|
+
// Mirrors OpenAI's `ResponseIncludable` union from the official SDK. Keep this
|
|
11
|
+
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
|
|
12
|
+
export const OpenAIResponseIncludables = [
|
|
13
|
+
"file_search_call.results",
|
|
14
|
+
"web_search_call.results",
|
|
15
|
+
"web_search_call.action.sources",
|
|
16
|
+
"message.input_image.image_url",
|
|
17
|
+
"computer_call_output.output.image_url",
|
|
18
|
+
"code_interpreter_call.outputs",
|
|
19
|
+
"reasoning.encrypted_content",
|
|
20
|
+
"message.output_text.logprobs",
|
|
21
|
+
] as const
|
|
22
|
+
export type OpenAIResponseIncludable = (typeof OpenAIResponseIncludables)[number]
|
|
23
|
+
|
|
24
|
+
const REASONING_EFFORTS = new Set<string>(ReasoningEfforts)
|
|
25
|
+
const OPENAI_REASONING_EFFORTS = new Set<string>(OpenAIReasoningEfforts)
|
|
26
|
+
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
|
|
27
|
+
const INCLUDABLES = new Set<string>(OpenAIResponseIncludables)
|
|
28
|
+
|
|
29
|
+
export const OpenAIReasoningEffort = Schema.Literals(OpenAIReasoningEfforts)
|
|
30
|
+
export const OpenAITextVerbosity = TextVerbosity
|
|
31
|
+
export const OpenAIResponseIncludable = Schema.Literals(OpenAIResponseIncludables)
|
|
32
|
+
|
|
33
|
+
const isAnyReasoningEffort = (effort: unknown): effort is ReasoningEffort =>
|
|
34
|
+
typeof effort === "string" && REASONING_EFFORTS.has(effort)
|
|
35
|
+
|
|
36
|
+
export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort =>
|
|
37
|
+
typeof effort === "string" && OPENAI_REASONING_EFFORTS.has(effort)
|
|
38
|
+
|
|
39
|
+
const isTextVerbosity = (value: unknown): value is TextVerbosityValue =>
|
|
40
|
+
typeof value === "string" && TEXT_VERBOSITY.has(value)
|
|
41
|
+
|
|
42
|
+
const options = (request: LLMRequest) => request.providerOptions?.openai
|
|
43
|
+
|
|
44
|
+
export const store = (request: LLMRequest): boolean | undefined => {
|
|
45
|
+
const value = options(request)?.store
|
|
46
|
+
return typeof value === "boolean" ? value : undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const reasoningEffort = (request: LLMRequest): ReasoningEffort | undefined => {
|
|
50
|
+
const value = options(request)?.reasoningEffort
|
|
51
|
+
return isAnyReasoningEffort(value) ? value : undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const reasoningSummary = (request: LLMRequest): "auto" | undefined =>
|
|
55
|
+
options(request)?.reasoningSummary === "auto" ? "auto" : undefined
|
|
56
|
+
|
|
57
|
+
// Resolve the OpenAI Responses `include` field. Filters out unknown
|
|
58
|
+
// includable values defensively so a typo in upstream config drops the
|
|
59
|
+
// invalid entry instead of poisoning the wire body. An empty array (either
|
|
60
|
+
// passed directly or produced by filtering) is treated as "no include" and
|
|
61
|
+
// returns undefined so the request body omits the field entirely.
|
|
62
|
+
export const include = (request: LLMRequest): ReadonlyArray<OpenAIResponseIncludable> | undefined => {
|
|
63
|
+
const value = options(request)?.include
|
|
64
|
+
if (!Array.isArray(value)) return undefined
|
|
65
|
+
const filtered = value.filter((entry): entry is OpenAIResponseIncludable => INCLUDABLES.has(entry))
|
|
66
|
+
return filtered.length > 0 ? filtered : undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const promptCacheKey = (request: LLMRequest) => {
|
|
70
|
+
const value = options(request)?.promptCacheKey
|
|
71
|
+
return typeof value === "string" ? value : undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const textVerbosity = (request: LLMRequest) => {
|
|
75
|
+
const value = options(request)?.textVerbosity
|
|
76
|
+
return isTextVerbosity(value) ? value : undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const instructions = (request: LLMRequest) => {
|
|
80
|
+
const value = options(request)?.instructions
|
|
81
|
+
return typeof value === "string" ? value : undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export * as OpenAIOptions from "./openai-options"
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema"
|
|
3
|
+
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
|
|
4
|
+
|
|
5
|
+
type StreamKey = string | number
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One pending streamed tool call. Providers emit the tool identity and JSON
|
|
9
|
+
* argument text across separate chunks; `input` is the raw JSON string collected
|
|
10
|
+
* so far, not the parsed object.
|
|
11
|
+
*/
|
|
12
|
+
export interface PendingTool extends ToolAccumulator {
|
|
13
|
+
readonly providerExecuted?: boolean
|
|
14
|
+
readonly providerMetadata?: ProviderMetadata
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Sparse parser state keyed by the provider's stream-local tool identifier.
|
|
19
|
+
*
|
|
20
|
+
* This key is not the final tool-call id (`call_...`). It is the id/index the
|
|
21
|
+
* provider uses while streaming a partial call: OpenAI Chat / Anthropic /
|
|
22
|
+
* Bedrock use numeric content indexes, while OpenAI Responses uses string
|
|
23
|
+
* `item_id`s. The generic keeps each protocol internally consistent.
|
|
24
|
+
*/
|
|
25
|
+
export type State<K extends StreamKey> = Partial<Record<K, PendingTool>>
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Result of adding argument text to one pending tool call. It returns both the
|
|
29
|
+
* next `tools` state and the updated `tool` because parsers often need the
|
|
30
|
+
* current id/name immediately. `events` contains lifecycle and delta events
|
|
31
|
+
* produced by the append; metadata-only deltas update identity without output.
|
|
32
|
+
*/
|
|
33
|
+
export interface AppendOutcome<K extends StreamKey> {
|
|
34
|
+
readonly tools: State<K>
|
|
35
|
+
readonly tool: PendingTool
|
|
36
|
+
readonly events: ReadonlyArray<LLMEvent>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Create empty accumulator state for one provider stream. */
|
|
40
|
+
export const empty = <K extends StreamKey>(): State<K> => ({})
|
|
41
|
+
|
|
42
|
+
const withTool = <K extends StreamKey>(tools: State<K>, key: K, tool: PendingTool): State<K> => {
|
|
43
|
+
return { ...tools, [key]: tool }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const withoutTool = <K extends StreamKey>(tools: State<K>, key: K): State<K> => {
|
|
47
|
+
const next = { ...tools }
|
|
48
|
+
delete next[key]
|
|
49
|
+
return next
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const inputStart = (tool: PendingTool) =>
|
|
53
|
+
LLMEvent.toolInputStart({
|
|
54
|
+
id: tool.id,
|
|
55
|
+
name: tool.name,
|
|
56
|
+
providerMetadata: tool.providerMetadata,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const inputDelta = (tool: PendingTool, text: string) =>
|
|
60
|
+
LLMEvent.toolInputDelta({
|
|
61
|
+
id: tool.id,
|
|
62
|
+
name: tool.name,
|
|
63
|
+
text,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) =>
|
|
67
|
+
parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe(
|
|
68
|
+
Effect.map(
|
|
69
|
+
(input): ToolCall =>
|
|
70
|
+
LLMEvent.toolCall({
|
|
71
|
+
id: tool.id,
|
|
72
|
+
name: tool.name,
|
|
73
|
+
input,
|
|
74
|
+
providerExecuted: tool.providerExecuted ? true : undefined,
|
|
75
|
+
providerMetadata: tool.providerMetadata,
|
|
76
|
+
}),
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
/** Store the updated tool and produce the optional public delta event. */
|
|
81
|
+
const appendTool = <K extends StreamKey>(
|
|
82
|
+
tools: State<K>,
|
|
83
|
+
key: K,
|
|
84
|
+
tool: PendingTool,
|
|
85
|
+
text: string,
|
|
86
|
+
): AppendOutcome<K> => {
|
|
87
|
+
const events: LLMEvent[] = []
|
|
88
|
+
if (!tools[key]) events.push(inputStart(tool))
|
|
89
|
+
if (text.length > 0) events.push(inputDelta(tool, text))
|
|
90
|
+
return {
|
|
91
|
+
tools: withTool(tools, key, tool),
|
|
92
|
+
tool,
|
|
93
|
+
events,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const isError = <K extends StreamKey>(result: AppendOutcome<K> | LLMError): result is LLMError =>
|
|
98
|
+
result instanceof LLMError
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Register a tool call whose start event arrived before any argument deltas.
|
|
102
|
+
* Used by Anthropic `content_block_start`, Bedrock `contentBlockStart`, and
|
|
103
|
+
* OpenAI Responses `response.output_item.added`.
|
|
104
|
+
*/
|
|
105
|
+
export const start = <K extends StreamKey>(
|
|
106
|
+
tools: State<K>,
|
|
107
|
+
key: K,
|
|
108
|
+
tool: Omit<PendingTool, "input"> & { readonly input?: string },
|
|
109
|
+
) => withTool(tools, key, { ...tool, input: tool.input ?? "" })
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Append a streamed argument delta, starting the tool if this provider encodes
|
|
113
|
+
* identity on the first delta instead of a separate start event. OpenAI Chat has
|
|
114
|
+
* this shape: `tool_calls[].index` is the stream key, and `id` / `name` may only
|
|
115
|
+
* appear on the first delta for that index.
|
|
116
|
+
*/
|
|
117
|
+
export const appendOrStart = <K extends StreamKey>(
|
|
118
|
+
route: string,
|
|
119
|
+
tools: State<K>,
|
|
120
|
+
key: K,
|
|
121
|
+
delta: { readonly id?: string; readonly name?: string; readonly text: string },
|
|
122
|
+
missingToolMessage: string,
|
|
123
|
+
): AppendOutcome<K> | LLMError => {
|
|
124
|
+
const current = tools[key]
|
|
125
|
+
const id = delta.id ?? current?.id
|
|
126
|
+
const name = delta.name ?? current?.name
|
|
127
|
+
if (!id || !name) return eventError(route, missingToolMessage)
|
|
128
|
+
|
|
129
|
+
const tool = {
|
|
130
|
+
id,
|
|
131
|
+
name,
|
|
132
|
+
input: `${current?.input ?? ""}${delta.text}`,
|
|
133
|
+
providerExecuted: current?.providerExecuted,
|
|
134
|
+
providerMetadata: current?.providerMetadata,
|
|
135
|
+
}
|
|
136
|
+
if (current && delta.text.length === 0 && current.id === id && current.name === name)
|
|
137
|
+
return { tools, tool: current, events: [] }
|
|
138
|
+
return appendTool(tools, key, tool, delta.text)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Append argument text to a tool that must already have been started. This keeps
|
|
143
|
+
* protocols honest when their stream grammar promises a start event before any
|
|
144
|
+
* argument delta.
|
|
145
|
+
*/
|
|
146
|
+
export const appendExisting = <K extends StreamKey>(
|
|
147
|
+
route: string,
|
|
148
|
+
tools: State<K>,
|
|
149
|
+
key: K,
|
|
150
|
+
text: string,
|
|
151
|
+
missingToolMessage: string,
|
|
152
|
+
): AppendOutcome<K> | LLMError => {
|
|
153
|
+
const current = tools[key]
|
|
154
|
+
if (!current) return eventError(route, missingToolMessage)
|
|
155
|
+
if (text.length === 0) return { tools, tool: current, events: [] }
|
|
156
|
+
return appendTool(tools, key, { ...current, input: `${current.input}${text}` }, text)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Finalize one pending tool call: parse the accumulated raw JSON, remove it
|
|
161
|
+
* from state, and return the optional public `tool-call` event. Missing keys are
|
|
162
|
+
* a no-op because some providers emit stop events for non-tool content blocks.
|
|
163
|
+
*/
|
|
164
|
+
export const finish = <K extends StreamKey>(route: string, tools: State<K>, key: K) =>
|
|
165
|
+
Effect.gen(function* () {
|
|
166
|
+
const tool = tools[key]
|
|
167
|
+
if (!tool) return { tools }
|
|
168
|
+
return {
|
|
169
|
+
tools: withoutTool(tools, key),
|
|
170
|
+
events: [
|
|
171
|
+
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
|
|
172
|
+
yield* toolCall(route, tool),
|
|
173
|
+
],
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Finalize one pending tool call with an authoritative final input string.
|
|
179
|
+
* OpenAI Responses can send accumulated deltas and then repeat the completed
|
|
180
|
+
* arguments on `response.output_item.done`; the final value wins.
|
|
181
|
+
*/
|
|
182
|
+
export const finishWithInput = <K extends StreamKey>(route: string, tools: State<K>, key: K, input: string) =>
|
|
183
|
+
Effect.gen(function* () {
|
|
184
|
+
const tool = tools[key]
|
|
185
|
+
if (!tool) return { tools }
|
|
186
|
+
return {
|
|
187
|
+
tools: withoutTool(tools, key),
|
|
188
|
+
events: [
|
|
189
|
+
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
|
|
190
|
+
yield* toolCall(route, tool, input),
|
|
191
|
+
],
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Finalize every pending tool call at once. OpenAI Chat has this shape: it does
|
|
197
|
+
* not emit per-tool stop events, so all accumulated calls finish when the choice
|
|
198
|
+
* receives a terminal `finish_reason`.
|
|
199
|
+
*/
|
|
200
|
+
export const finishAll = <K extends StreamKey>(route: string, tools: State<K>) =>
|
|
201
|
+
Effect.gen(function* () {
|
|
202
|
+
const pending = Object.values<PendingTool | undefined>(tools).filter(
|
|
203
|
+
(tool): tool is PendingTool => tool !== undefined,
|
|
204
|
+
)
|
|
205
|
+
return {
|
|
206
|
+
tools: empty<K>(),
|
|
207
|
+
events: yield* Effect.forEach(pending, (tool) =>
|
|
208
|
+
toolCall(route, tool).pipe(
|
|
209
|
+
Effect.map((call) => [
|
|
210
|
+
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
|
|
211
|
+
call,
|
|
212
|
+
]),
|
|
213
|
+
),
|
|
214
|
+
).pipe(Effect.map((events) => events.flat())),
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
export * as ToolStream from "./tool-stream"
|
package/src/provider.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { RouteDefaultsInput } from "./route/client"
|
|
2
|
+
import type { Model, ModelID, ProviderID } from "./schema"
|
|
3
|
+
|
|
4
|
+
export type ModelOptions = RouteDefaultsInput
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Advanced structural provider definition helper. Built-in providers should
|
|
8
|
+
* prefer explicit `configure(options).model(id)` facades so deployment config is
|
|
9
|
+
* chosen before model selection. The optional `apis` map remains for external
|
|
10
|
+
* structural providers that expose multiple route selectors behind one provider.
|
|
11
|
+
*/
|
|
12
|
+
export type ModelFactory<Options extends ModelOptions = ModelOptions> = (
|
|
13
|
+
id: string | ModelID,
|
|
14
|
+
options?: Options,
|
|
15
|
+
) => Model
|
|
16
|
+
|
|
17
|
+
type AnyModelFactory = (...args: never[]) => Model
|
|
18
|
+
|
|
19
|
+
export interface Definition<Factory extends AnyModelFactory = ModelFactory> {
|
|
20
|
+
readonly id: ProviderID
|
|
21
|
+
readonly model: Factory
|
|
22
|
+
readonly apis?: Record<string, AnyModelFactory>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type DefinitionShape = {
|
|
26
|
+
readonly id: ProviderID
|
|
27
|
+
readonly model: (...args: never[]) => Model
|
|
28
|
+
readonly apis?: Record<string, (...args: never[]) => Model>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type NoExtraFields<Input, Shape> = Input & Record<Exclude<keyof Input, keyof Shape>, never>
|
|
32
|
+
|
|
33
|
+
export const make = <DefinitionType extends DefinitionShape>(
|
|
34
|
+
definition: NoExtraFields<DefinitionType, DefinitionShape>,
|
|
35
|
+
) => definition
|
|
36
|
+
|
|
37
|
+
export * as Provider from "./provider"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { RouteDefaultsInput } from "../route/client"
|
|
2
|
+
import { Auth } from "../route/auth"
|
|
3
|
+
import { ProviderID, type ModelID } from "../schema"
|
|
4
|
+
import * as BedrockConverse from "../protocols/bedrock-converse"
|
|
5
|
+
import type { BedrockCredentials } from "../protocols/bedrock-converse"
|
|
6
|
+
|
|
7
|
+
export const id = ProviderID.make("amazon-bedrock")
|
|
8
|
+
|
|
9
|
+
export type Config = RouteDefaultsInput & {
|
|
10
|
+
readonly apiKey?: string
|
|
11
|
+
readonly headers?: Record<string, string>
|
|
12
|
+
readonly credentials?: BedrockCredentials
|
|
13
|
+
/** AWS region. Defaults to `us-east-1` when neither this nor `credentials.region` is set. */
|
|
14
|
+
readonly region?: string
|
|
15
|
+
/** Override the computed `https://bedrock-runtime.<region>.amazonaws.com` URL. */
|
|
16
|
+
readonly baseURL?: string
|
|
17
|
+
}
|
|
18
|
+
export const routes = [BedrockConverse.route]
|
|
19
|
+
|
|
20
|
+
const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.amazonaws.com`
|
|
21
|
+
|
|
22
|
+
const configuredRoute = (input: Config) => {
|
|
23
|
+
const { apiKey, credentials, region, baseURL, ...rest } = input
|
|
24
|
+
const resolvedRegion = region ?? credentials?.region ?? "us-east-1"
|
|
25
|
+
return BedrockConverse.route.with({
|
|
26
|
+
...rest,
|
|
27
|
+
provider: id,
|
|
28
|
+
endpoint: { baseURL: baseURL ?? bedrockBaseURL(resolvedRegion) },
|
|
29
|
+
auth: apiKey === undefined ? BedrockConverse.sigV4Auth(credentials) : Auth.bearer(apiKey),
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const configure = (input: Config = {}) => {
|
|
34
|
+
const route = configuredRoute(input)
|
|
35
|
+
return {
|
|
36
|
+
id,
|
|
37
|
+
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
|
38
|
+
configure,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const provider = configure()
|
|
43
|
+
export const model = provider.model
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { RouteDefaultsInput } from "../route/client"
|
|
2
|
+
import { Auth } from "../route/auth"
|
|
3
|
+
import type { ProviderAuthOption } from "../route/auth-options"
|
|
4
|
+
import { ProviderID, type ModelID } from "../schema"
|
|
5
|
+
import * as AnthropicMessages from "../protocols/anthropic-messages"
|
|
6
|
+
|
|
7
|
+
export const id = ProviderID.make("anthropic")
|
|
8
|
+
|
|
9
|
+
export const routes = [AnthropicMessages.route]
|
|
10
|
+
|
|
11
|
+
export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string }
|
|
12
|
+
|
|
13
|
+
const auth = (options: ProviderAuthOption<"optional">) => {
|
|
14
|
+
if ("auth" in options && options.auth) return options.auth
|
|
15
|
+
return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
|
|
16
|
+
.orElse(Auth.config("ANTHROPIC_API_KEY"))
|
|
17
|
+
.pipe(Auth.header("x-api-key"))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const configuredRoute = (input: Config) => {
|
|
21
|
+
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
|
|
22
|
+
return AnthropicMessages.route.with({ ...rest, endpoint: { baseURL }, auth: auth(input) })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const configure = (input: Config = {}) => {
|
|
26
|
+
const route = configuredRoute(input)
|
|
27
|
+
return {
|
|
28
|
+
id,
|
|
29
|
+
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
|
30
|
+
configure,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const provider = configure()
|
|
35
|
+
export const model = provider.model
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Auth } from "../route/auth"
|
|
2
|
+
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options"
|
|
3
|
+
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client"
|
|
4
|
+
import { ProviderID, type ModelID } from "../schema"
|
|
5
|
+
import * as OpenAIChat from "../protocols/openai-chat"
|
|
6
|
+
import * as OpenAIResponses from "../protocols/openai-responses"
|
|
7
|
+
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
|
|
8
|
+
|
|
9
|
+
export const id = ProviderID.make("azure")
|
|
10
|
+
const routeAuth = Auth.remove("authorization")
|
|
11
|
+
|
|
12
|
+
// Azure needs the customer's resource URL; supply either `resourceName`
|
|
13
|
+
// (helper builds the URL) or `baseURL` directly.
|
|
14
|
+
type AzureURL = AtLeastOne<{ readonly resourceName: string; readonly baseURL: string }>
|
|
15
|
+
|
|
16
|
+
export type ModelOptions = AzureURL &
|
|
17
|
+
RouteDefaultsInput &
|
|
18
|
+
ProviderAuthOption<"optional"> & {
|
|
19
|
+
readonly apiVersion?: string
|
|
20
|
+
readonly queryParams?: Record<string, string>
|
|
21
|
+
readonly useCompletionUrls?: boolean
|
|
22
|
+
readonly providerOptions?: OpenAIProviderOptionsInput
|
|
23
|
+
}
|
|
24
|
+
export type Config = ModelOptions
|
|
25
|
+
|
|
26
|
+
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai/v1`
|
|
27
|
+
|
|
28
|
+
const responsesRoute = OpenAIResponses.route.with({
|
|
29
|
+
id: "azure-openai-responses",
|
|
30
|
+
provider: id,
|
|
31
|
+
auth: routeAuth,
|
|
32
|
+
endpoint: {
|
|
33
|
+
query: { "api-version": "v1" },
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const chatRoute = OpenAIChat.route.with({
|
|
38
|
+
id: "azure-openai-chat",
|
|
39
|
+
provider: id,
|
|
40
|
+
auth: routeAuth,
|
|
41
|
+
endpoint: {
|
|
42
|
+
query: { "api-version": "v1" },
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
export const routes = [responsesRoute, chatRoute]
|
|
47
|
+
|
|
48
|
+
const defaults = (input: Config) => {
|
|
49
|
+
const {
|
|
50
|
+
apiKey: _,
|
|
51
|
+
apiVersion: _apiVersion,
|
|
52
|
+
resourceName: _resourceName,
|
|
53
|
+
useCompletionUrls: _useCompletionUrls,
|
|
54
|
+
baseURL: _baseURL,
|
|
55
|
+
queryParams: _queryParams,
|
|
56
|
+
...rest
|
|
57
|
+
} = input
|
|
58
|
+
if ("auth" in rest) {
|
|
59
|
+
const { auth: _, ...withoutAuth } = rest
|
|
60
|
+
return withoutAuth
|
|
61
|
+
}
|
|
62
|
+
return rest
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const auth = (input: Config) => {
|
|
66
|
+
if ("auth" in input && input.auth) return input.auth
|
|
67
|
+
return Auth.remove("authorization").andThen(
|
|
68
|
+
Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey")
|
|
69
|
+
.orElse(Auth.config("AZURE_OPENAI_API_KEY"))
|
|
70
|
+
.pipe(Auth.header("api-key")),
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config) =>
|
|
75
|
+
route.with({
|
|
76
|
+
auth: auth(input),
|
|
77
|
+
endpoint: {
|
|
78
|
+
// AtLeastOne guarantees at least one is set; baseURL wins if both are.
|
|
79
|
+
baseURL: input.baseURL ?? resourceBaseURL(input.resourceName!),
|
|
80
|
+
query: {
|
|
81
|
+
...(input.apiVersion ? { "api-version": input.apiVersion } : {}),
|
|
82
|
+
...input.queryParams,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
export const configure = (input: Config) => {
|
|
88
|
+
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
|
|
89
|
+
const configuredChatRoute = configuredRoute(chatRoute, input)
|
|
90
|
+
const modelDefaults = defaults(input)
|
|
91
|
+
|
|
92
|
+
const responses = (modelID: string | ModelID) =>
|
|
93
|
+
configuredResponsesRoute.with(withOpenAIOptions(modelID, modelDefaults)).model({ id: modelID })
|
|
94
|
+
|
|
95
|
+
const chat = (modelID: string | ModelID) =>
|
|
96
|
+
configuredChatRoute.with(withOpenAIOptions(modelID, modelDefaults)).model({ id: modelID })
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
id,
|
|
100
|
+
model: (modelID: string | ModelID) => (input.useCompletionUrls === true ? chat(modelID) : responses(modelID)),
|
|
101
|
+
responses,
|
|
102
|
+
chat,
|
|
103
|
+
configure,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const provider = {
|
|
108
|
+
id,
|
|
109
|
+
configure,
|
|
110
|
+
}
|