@a-dray/aglib 0.1.0
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/LICENSE +21 -0
- package/README.md +172 -0
- package/dist/agent.d.ts +158 -0
- package/dist/agent.js +2 -0
- package/dist/agent.js.map +1 -0
- package/dist/content.d.ts +45 -0
- package/dist/content.js +11 -0
- package/dist/content.js.map +1 -0
- package/dist/harness/adapters/acp/index.d.ts +92 -0
- package/dist/harness/adapters/acp/index.js +479 -0
- package/dist/harness/adapters/acp/index.js.map +1 -0
- package/dist/harness/adapters/acp/rpc.d.ts +23 -0
- package/dist/harness/adapters/acp/rpc.js +103 -0
- package/dist/harness/adapters/acp/rpc.js.map +1 -0
- package/dist/harness/adapters/native/compaction.d.ts +16 -0
- package/dist/harness/adapters/native/compaction.js +55 -0
- package/dist/harness/adapters/native/compaction.js.map +1 -0
- package/dist/harness/adapters/native/index.d.ts +2 -0
- package/dist/harness/adapters/native/index.js +2 -0
- package/dist/harness/adapters/native/index.js.map +1 -0
- package/dist/harness/adapters/native/loop.d.ts +25 -0
- package/dist/harness/adapters/native/loop.js +153 -0
- package/dist/harness/adapters/native/loop.js.map +1 -0
- package/dist/harness/harness.d.ts +129 -0
- package/dist/harness/harness.js +2 -0
- package/dist/harness/harness.js.map +1 -0
- package/dist/harness/index.d.ts +3 -0
- package/dist/harness/index.js +2 -0
- package/dist/harness/index.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/json.d.ts +4 -0
- package/dist/json.js +2 -0
- package/dist/json.js.map +1 -0
- package/dist/model/adapters/anthropic/index.d.ts +34 -0
- package/dist/model/adapters/anthropic/index.js +271 -0
- package/dist/model/adapters/anthropic/index.js.map +1 -0
- package/dist/model/adapters/fake/index.d.ts +27 -0
- package/dist/model/adapters/fake/index.js +37 -0
- package/dist/model/adapters/fake/index.js.map +1 -0
- package/dist/model/adapters/openai-compatible/index.d.ts +29 -0
- package/dist/model/adapters/openai-compatible/index.js +226 -0
- package/dist/model/adapters/openai-compatible/index.js.map +1 -0
- package/dist/model/conformance.d.ts +210 -0
- package/dist/model/conformance.js +427 -0
- package/dist/model/conformance.js.map +1 -0
- package/dist/model/index.d.ts +2 -0
- package/dist/model/index.js +2 -0
- package/dist/model/index.js.map +1 -0
- package/dist/model/model.d.ts +85 -0
- package/dist/model/model.js +8 -0
- package/dist/model/model.js.map +1 -0
- package/dist/result.d.ts +23 -0
- package/dist/result.js +3 -0
- package/dist/result.js.map +1 -0
- package/dist/run.d.ts +10 -0
- package/dist/run.js +477 -0
- package/dist/run.js.map +1 -0
- package/dist/sandbox/adapters/docker.d.ts +22 -0
- package/dist/sandbox/adapters/docker.js +443 -0
- package/dist/sandbox/adapters/docker.js.map +1 -0
- package/dist/sandbox/adapters/local.d.ts +11 -0
- package/dist/sandbox/adapters/local.js +284 -0
- package/dist/sandbox/adapters/local.js.map +1 -0
- package/dist/sandbox/conformance.d.ts +73 -0
- package/dist/sandbox/conformance.js +309 -0
- package/dist/sandbox/conformance.js.map +1 -0
- package/dist/sandbox/index.d.ts +1 -0
- package/dist/sandbox/index.js +2 -0
- package/dist/sandbox/index.js.map +1 -0
- package/dist/sandbox/sandbox.d.ts +172 -0
- package/dist/sandbox/sandbox.js +2 -0
- package/dist/sandbox/sandbox.js.map +1 -0
- package/dist/session/entry.d.ts +196 -0
- package/dist/session/entry.js +2 -0
- package/dist/session/entry.js.map +1 -0
- package/dist/session/index.d.ts +2 -0
- package/dist/session/index.js +2 -0
- package/dist/session/index.js.map +1 -0
- package/dist/session/log.d.ts +20 -0
- package/dist/session/log.js +24 -0
- package/dist/session/log.js.map +1 -0
- package/dist/session/messages.d.ts +42 -0
- package/dist/session/messages.js +99 -0
- package/dist/session/messages.js.map +1 -0
- package/dist/store/adapters/sqlite.d.ts +20 -0
- package/dist/store/adapters/sqlite.js +365 -0
- package/dist/store/adapters/sqlite.js.map +1 -0
- package/dist/store/conformance.d.ts +66 -0
- package/dist/store/conformance.js +580 -0
- package/dist/store/conformance.js.map +1 -0
- package/dist/store/index.d.ts +1 -0
- package/dist/store/index.js +2 -0
- package/dist/store/index.js.map +1 -0
- package/dist/store/store.d.ts +244 -0
- package/dist/store/store.js +2 -0
- package/dist/store/store.js.map +1 -0
- package/dist/tools/execute.d.ts +24 -0
- package/dist/tools/execute.js +105 -0
- package/dist/tools/execute.js.map +1 -0
- package/dist/tools/index.d.ts +2 -0
- package/dist/tools/index.js +2 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/tool.d.ts +106 -0
- package/dist/tools/tool.js +34 -0
- package/dist/tools/tool.js.map +1 -0
- package/package.json +119 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { err, ok } from "../../../result.js";
|
|
2
|
+
import { textOf } from "../../../content.js";
|
|
3
|
+
/**
|
|
4
|
+
* The OpenAI chat-completions wire, which almost every provider now speaks —
|
|
5
|
+
* OpenRouter, Together, Groq, vLLM, Ollama. One adapter, one base URL.
|
|
6
|
+
*/
|
|
7
|
+
export function createOpenAiCompatibleModel(options) {
|
|
8
|
+
const call = options.fetch ?? fetch;
|
|
9
|
+
return {
|
|
10
|
+
id: `openai-compatible:${options.model}`,
|
|
11
|
+
async *generate(request) {
|
|
12
|
+
let response;
|
|
13
|
+
try {
|
|
14
|
+
response = await call(`${options.baseUrl}/chat/completions`, {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: {
|
|
17
|
+
"content-type": "application/json",
|
|
18
|
+
authorization: `Bearer ${options.apiKey}`,
|
|
19
|
+
...options.headers,
|
|
20
|
+
},
|
|
21
|
+
body: JSON.stringify({
|
|
22
|
+
model: options.model,
|
|
23
|
+
messages: request.messages.map(encodeMessage),
|
|
24
|
+
...(request.tools?.length ? { tools: request.tools.map(encodeTool) } : {}),
|
|
25
|
+
...(request.maxOutputTokens !== undefined ? { max_tokens: request.maxOutputTokens } : {}),
|
|
26
|
+
...(request.temperature !== undefined ? { temperature: request.temperature } : {}),
|
|
27
|
+
...encodeEffort(request.effort, options.effortParameter ?? "reasoning_effort"),
|
|
28
|
+
stream: true,
|
|
29
|
+
stream_options: { include_usage: true },
|
|
30
|
+
}),
|
|
31
|
+
...(request.signal ? { signal: request.signal } : {}),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
return err(transportError(error, request.signal));
|
|
36
|
+
}
|
|
37
|
+
if (!response.ok)
|
|
38
|
+
return err(await httpError(response));
|
|
39
|
+
if (!response.body)
|
|
40
|
+
return err({ code: "provider", message: "No response body", retryable: true });
|
|
41
|
+
const text = [];
|
|
42
|
+
const calls = new Map();
|
|
43
|
+
let finish = "stop";
|
|
44
|
+
let usage = {};
|
|
45
|
+
let model;
|
|
46
|
+
// A stream that dies mid-body — cancelled, dropped, truncated — must come
|
|
47
|
+
// back as a typed failure like any other. Without this the generator throws,
|
|
48
|
+
// `run.result` rejects instead of resolving `cancelled`, no `run.finished`
|
|
49
|
+
// is ever committed, and the activation stays open until its claim expires.
|
|
50
|
+
try {
|
|
51
|
+
for await (const event of sseLines(response.body)) {
|
|
52
|
+
if (event === "[DONE]")
|
|
53
|
+
break;
|
|
54
|
+
let frame;
|
|
55
|
+
try {
|
|
56
|
+
frame = JSON.parse(event);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
model ??= frame.model;
|
|
62
|
+
if (frame.usage)
|
|
63
|
+
usage = decodeUsage(frame.usage);
|
|
64
|
+
const choice = frame.choices?.[0];
|
|
65
|
+
if (!choice)
|
|
66
|
+
continue;
|
|
67
|
+
if (choice.finish_reason)
|
|
68
|
+
finish = decodeFinish(choice.finish_reason);
|
|
69
|
+
const content = choice.delta?.content;
|
|
70
|
+
if (content) {
|
|
71
|
+
text.push(content);
|
|
72
|
+
yield { type: "text.delta", text: content };
|
|
73
|
+
}
|
|
74
|
+
const reasoning = choice.delta?.reasoning;
|
|
75
|
+
if (reasoning)
|
|
76
|
+
yield { type: "reasoning.delta", text: reasoning };
|
|
77
|
+
for (const fragment of choice.delta?.tool_calls ?? []) {
|
|
78
|
+
// Providers stream tool arguments in fragments, keyed by position;
|
|
79
|
+
// only the first fragment carries the id and the name.
|
|
80
|
+
const existing = calls.get(fragment.index) ?? {
|
|
81
|
+
callId: fragment.id ?? `call_${fragment.index}`,
|
|
82
|
+
name: fragment.function?.name ?? "",
|
|
83
|
+
arguments: "",
|
|
84
|
+
};
|
|
85
|
+
if (fragment.id)
|
|
86
|
+
existing.callId = fragment.id;
|
|
87
|
+
if (fragment.function?.name)
|
|
88
|
+
existing.name = fragment.function.name;
|
|
89
|
+
if (fragment.function?.arguments) {
|
|
90
|
+
existing.arguments += fragment.function.arguments;
|
|
91
|
+
yield { type: "tool-call.delta", callId: existing.callId, arguments: fragment.function.arguments };
|
|
92
|
+
}
|
|
93
|
+
calls.set(fragment.index, existing);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
return err(transportError(error, request.signal));
|
|
99
|
+
}
|
|
100
|
+
const collected = [...calls.values()]
|
|
101
|
+
.map((call) => ({ callId: call.callId, name: call.name, arguments: call.arguments || "{}" }));
|
|
102
|
+
return ok({
|
|
103
|
+
message: { content: text.join(""), ...(collected.length ? { calls: collected } : {}) },
|
|
104
|
+
finishReason: collected.length && finish === "stop" ? "tool-calls" : finish,
|
|
105
|
+
usage,
|
|
106
|
+
...(model ? { model } : {}),
|
|
107
|
+
});
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** OpenRouter is this wire with a fixed base URL and attribution headers. */
|
|
112
|
+
export function createOpenRouterModel(input) {
|
|
113
|
+
return createOpenAiCompatibleModel({
|
|
114
|
+
apiKey: input.apiKey,
|
|
115
|
+
baseUrl: "https://openrouter.ai/api/v1",
|
|
116
|
+
model: input.model,
|
|
117
|
+
effortParameter: "reasoning",
|
|
118
|
+
headers: {
|
|
119
|
+
...(input.appUrl ? { "http-referer": input.appUrl } : {}),
|
|
120
|
+
...(input.appName ? { "x-title": input.appName } : {}),
|
|
121
|
+
},
|
|
122
|
+
...(input.fetch ? { fetch: input.fetch } : {}),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
function encodeEffort(effort, parameter) {
|
|
126
|
+
if (!effort || parameter === "none")
|
|
127
|
+
return {};
|
|
128
|
+
return parameter === "reasoning" ? { reasoning: { effort } } : { reasoning_effort: effort };
|
|
129
|
+
}
|
|
130
|
+
function encodeMessage(message) {
|
|
131
|
+
if (message.role === "tool") {
|
|
132
|
+
return { role: "tool", tool_call_id: message.callId, content: textOf(message.content) };
|
|
133
|
+
}
|
|
134
|
+
if (message.role === "assistant") {
|
|
135
|
+
return {
|
|
136
|
+
role: "assistant",
|
|
137
|
+
content: textOf(message.content) || null,
|
|
138
|
+
...(message.calls?.length
|
|
139
|
+
? {
|
|
140
|
+
tool_calls: message.calls.map((call) => ({
|
|
141
|
+
id: call.callId, type: "function",
|
|
142
|
+
function: { name: call.name, arguments: call.arguments },
|
|
143
|
+
})),
|
|
144
|
+
}
|
|
145
|
+
: {}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return { role: message.role, content: encodeContent(message.content) };
|
|
149
|
+
}
|
|
150
|
+
function encodeContent(content) {
|
|
151
|
+
if (typeof content === "string")
|
|
152
|
+
return content;
|
|
153
|
+
const parts = [];
|
|
154
|
+
for (const part of content) {
|
|
155
|
+
if (part.type === "text") {
|
|
156
|
+
parts.push({ type: "text", text: part.text });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (part.type === "image") {
|
|
160
|
+
const url = part.source.kind === "url" ? part.source.url : `data:${part.mediaType};base64,${part.source.data}`;
|
|
161
|
+
parts.push({ type: "image_url", image_url: { url } });
|
|
162
|
+
}
|
|
163
|
+
// Files and opaque blocks have no place on this wire. Dropping them beats
|
|
164
|
+
// stringifying them into something the model would read as prose.
|
|
165
|
+
}
|
|
166
|
+
const only = parts.length === 1 ? parts[0] : undefined;
|
|
167
|
+
return only?.type === "text" ? only.text : parts;
|
|
168
|
+
}
|
|
169
|
+
const encodeTool = (tool) => ({
|
|
170
|
+
type: "function",
|
|
171
|
+
function: { name: tool.name, description: tool.description, parameters: tool.parameters },
|
|
172
|
+
});
|
|
173
|
+
const decodeFinish = (reason) => reason === "tool_calls" ? "tool-calls"
|
|
174
|
+
: reason === "length" ? "length"
|
|
175
|
+
: reason === "content_filter" ? "refusal"
|
|
176
|
+
: "stop";
|
|
177
|
+
const decodeUsage = (usage) => ({
|
|
178
|
+
// Carried, not computed. A router picks an upstream provider per request and
|
|
179
|
+
// adds its own margin, so what it charged is a thing only it can say — and
|
|
180
|
+
// the frame carrying it is the one this adapter already reads.
|
|
181
|
+
...(typeof usage.cost === "number" ? { costUsd: usage.cost } : {}),
|
|
182
|
+
// `prompt_tokens` is the whole prompt and `cached_tokens` is counted inside
|
|
183
|
+
// it — "cached tokens present in the prompt". `Usage` keeps the three input
|
|
184
|
+
// counts disjoint, so the cached part comes out here rather than every
|
|
185
|
+
// caller having to know which wire produced the number it is holding.
|
|
186
|
+
...(usage.prompt_tokens !== undefined
|
|
187
|
+
? { inputTokens: Math.max(usage.prompt_tokens - (usage.prompt_tokens_details?.cached_tokens ?? 0), 0) } : {}),
|
|
188
|
+
...(usage.completion_tokens !== undefined ? { outputTokens: usage.completion_tokens } : {}),
|
|
189
|
+
...(usage.prompt_tokens_details?.cached_tokens !== undefined
|
|
190
|
+
? { cacheReadTokens: usage.prompt_tokens_details.cached_tokens } : {}),
|
|
191
|
+
});
|
|
192
|
+
async function httpError(response) {
|
|
193
|
+
const body = await response.text().catch(() => "");
|
|
194
|
+
const code = response.status === 401 || response.status === 403 ? "auth"
|
|
195
|
+
: response.status === 429 ? "rate-limit"
|
|
196
|
+
: response.status === 400 && /context|token/i.test(body) ? "context-length"
|
|
197
|
+
: response.status >= 500 ? "provider"
|
|
198
|
+
: "failed";
|
|
199
|
+
return {
|
|
200
|
+
code,
|
|
201
|
+
message: `${response.status} ${response.statusText}${body ? `: ${body.slice(0, 400)}` : ""}`,
|
|
202
|
+
retryable: code === "rate-limit" || code === "provider",
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
function transportError(error, signal) {
|
|
206
|
+
if (signal?.aborted)
|
|
207
|
+
return { code: "cancelled", message: "Generation cancelled", retryable: false };
|
|
208
|
+
return { code: "provider", message: error instanceof Error ? error.message : String(error), retryable: true };
|
|
209
|
+
}
|
|
210
|
+
/** Server-sent events, reassembled across chunk boundaries. */
|
|
211
|
+
async function* sseLines(body) {
|
|
212
|
+
const decoder = new TextDecoder();
|
|
213
|
+
let buffer = "";
|
|
214
|
+
for await (const chunk of body) {
|
|
215
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
216
|
+
let newline = buffer.indexOf("\n");
|
|
217
|
+
while (newline >= 0) {
|
|
218
|
+
const line = buffer.slice(0, newline).trim();
|
|
219
|
+
buffer = buffer.slice(newline + 1);
|
|
220
|
+
if (line.startsWith("data:"))
|
|
221
|
+
yield line.slice(5).trim();
|
|
222
|
+
newline = buffer.indexOf("\n");
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/model/adapters/openai-compatible/index.ts"],"names":[],"mappings":"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","sourcesContent":["import type {\n ContentPart, Message, Model, ModelDelta, ModelError, ModelRequest, ModelResponse, ToolCall, ToolSpec, Usage,\n} from \"../../model.js\";\nimport { err, ok, type Result } from \"../../../result.js\";\nimport { textOf } from \"../../../content.js\";\n\nexport interface OpenAiCompatibleOptions {\n apiKey: string;\n baseUrl: string;\n model: string;\n headers?: Readonly<Record<string, string>>;\n /**\n * How this endpoint spells reasoning effort. OpenAI takes `reasoning_effort`;\n * OpenRouter takes `reasoning: { effort }` and forwards it to whoever is\n * serving the model. Sending both is not compatibility — OpenAI rejects the\n * request outright — so the dialect is declared rather than guessed.\n */\n effortParameter?: \"reasoning_effort\" | \"reasoning\" | \"none\";\n /** Injectable for tests; defaults to global fetch. */\n fetch?: typeof fetch;\n}\n\n/**\n * The OpenAI chat-completions wire, which almost every provider now speaks —\n * OpenRouter, Together, Groq, vLLM, Ollama. One adapter, one base URL.\n */\nexport function createOpenAiCompatibleModel(options: OpenAiCompatibleOptions): Model {\n const call = options.fetch ?? fetch;\n return {\n id: `openai-compatible:${options.model}`,\n\n async *generate(request: ModelRequest): AsyncGenerator<ModelDelta, Result<ModelResponse, ModelError>> {\n let response: Response;\n try {\n response = await call(`${options.baseUrl}/chat/completions`, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n authorization: `Bearer ${options.apiKey}`,\n ...options.headers,\n },\n body: JSON.stringify({\n model: options.model,\n messages: request.messages.map(encodeMessage),\n ...(request.tools?.length ? { tools: request.tools.map(encodeTool) } : {}),\n ...(request.maxOutputTokens !== undefined ? { max_tokens: request.maxOutputTokens } : {}),\n ...(request.temperature !== undefined ? { temperature: request.temperature } : {}),\n ...encodeEffort(request.effort, options.effortParameter ?? \"reasoning_effort\"),\n stream: true,\n stream_options: { include_usage: true },\n }),\n ...(request.signal ? { signal: request.signal } : {}),\n });\n } catch (error) {\n return err(transportError(error, request.signal));\n }\n\n if (!response.ok) return err(await httpError(response));\n if (!response.body) return err({ code: \"provider\", message: \"No response body\", retryable: true });\n\n const text: string[] = [];\n const calls = new Map<number, { callId: string; name: string; arguments: string }>();\n let finish: ModelResponse[\"finishReason\"] = \"stop\";\n let usage: Usage = {};\n let model: string | undefined;\n\n // A stream that dies mid-body — cancelled, dropped, truncated — must come\n // back as a typed failure like any other. Without this the generator throws,\n // `run.result` rejects instead of resolving `cancelled`, no `run.finished`\n // is ever committed, and the activation stays open until its claim expires.\n try {\n for await (const event of sseLines(response.body)) {\n if (event === \"[DONE]\") break;\n let frame: ChatFrame;\n try { frame = JSON.parse(event) as ChatFrame; } catch { continue; }\n model ??= frame.model;\n if (frame.usage) usage = decodeUsage(frame.usage);\n\n const choice = frame.choices?.[0];\n if (!choice) continue;\n if (choice.finish_reason) finish = decodeFinish(choice.finish_reason);\n\n const content = choice.delta?.content;\n if (content) { text.push(content); yield { type: \"text.delta\", text: content }; }\n\n const reasoning = choice.delta?.reasoning;\n if (reasoning) yield { type: \"reasoning.delta\", text: reasoning };\n\n for (const fragment of choice.delta?.tool_calls ?? []) {\n // Providers stream tool arguments in fragments, keyed by position;\n // only the first fragment carries the id and the name.\n const existing = calls.get(fragment.index) ?? {\n callId: fragment.id ?? `call_${fragment.index}`,\n name: fragment.function?.name ?? \"\",\n arguments: \"\",\n };\n if (fragment.id) existing.callId = fragment.id;\n if (fragment.function?.name) existing.name = fragment.function.name;\n if (fragment.function?.arguments) {\n existing.arguments += fragment.function.arguments;\n yield { type: \"tool-call.delta\", callId: existing.callId, arguments: fragment.function.arguments };\n }\n calls.set(fragment.index, existing);\n }\n }\n } catch (error) {\n return err(transportError(error, request.signal));\n }\n\n const collected: ToolCall[] = [...calls.values()]\n .map((call) => ({ callId: call.callId, name: call.name, arguments: call.arguments || \"{}\" }));\n\n return ok({\n message: { content: text.join(\"\"), ...(collected.length ? { calls: collected } : {}) },\n finishReason: collected.length && finish === \"stop\" ? \"tool-calls\" : finish,\n usage,\n ...(model ? { model } : {}),\n });\n },\n };\n}\n\n/** OpenRouter is this wire with a fixed base URL and attribution headers. */\nexport function createOpenRouterModel(input: {\n apiKey: string; model: string; appUrl?: string; appName?: string; fetch?: typeof fetch;\n}): Model {\n return createOpenAiCompatibleModel({\n apiKey: input.apiKey,\n baseUrl: \"https://openrouter.ai/api/v1\",\n model: input.model,\n effortParameter: \"reasoning\",\n headers: {\n ...(input.appUrl ? { \"http-referer\": input.appUrl } : {}),\n ...(input.appName ? { \"x-title\": input.appName } : {}),\n },\n ...(input.fetch ? { fetch: input.fetch } : {}),\n });\n}\n\nfunction encodeEffort(\n effort: ModelRequest[\"effort\"],\n parameter: \"reasoning_effort\" | \"reasoning\" | \"none\",\n): Record<string, unknown> {\n if (!effort || parameter === \"none\") return {};\n return parameter === \"reasoning\" ? { reasoning: { effort } } : { reasoning_effort: effort };\n}\n\nfunction encodeMessage(message: Message): Record<string, unknown> {\n if (message.role === \"tool\") {\n return { role: \"tool\", tool_call_id: message.callId, content: textOf(message.content) };\n }\n if (message.role === \"assistant\") {\n return {\n role: \"assistant\",\n content: textOf(message.content) || null,\n ...(message.calls?.length\n ? {\n tool_calls: message.calls.map((call) => ({\n id: call.callId, type: \"function\",\n function: { name: call.name, arguments: call.arguments },\n })),\n }\n : {}),\n };\n }\n return { role: message.role, content: encodeContent(message.content) };\n}\n\nfunction encodeContent(content: Message[\"content\"]): unknown {\n if (typeof content === \"string\") return content;\n const parts: Record<string, unknown>[] = [];\n for (const part of content as readonly ContentPart[]) {\n if (part.type === \"text\") { parts.push({ type: \"text\", text: part.text }); continue; }\n if (part.type === \"image\") {\n const url = part.source.kind === \"url\" ? part.source.url : `data:${part.mediaType};base64,${part.source.data}`;\n parts.push({ type: \"image_url\", image_url: { url } });\n }\n // Files and opaque blocks have no place on this wire. Dropping them beats\n // stringifying them into something the model would read as prose.\n }\n const only = parts.length === 1 ? parts[0] : undefined;\n return only?.type === \"text\" ? only.text : parts;\n}\n\nconst encodeTool = (tool: ToolSpec) => ({\n type: \"function\",\n function: { name: tool.name, description: tool.description, parameters: tool.parameters },\n});\n\nconst decodeFinish = (reason: string): ModelResponse[\"finishReason\"] =>\n reason === \"tool_calls\" ? \"tool-calls\"\n : reason === \"length\" ? \"length\"\n : reason === \"content_filter\" ? \"refusal\"\n : \"stop\";\n\nconst decodeUsage = (usage: ChatUsage): Usage => ({\n // Carried, not computed. A router picks an upstream provider per request and\n // adds its own margin, so what it charged is a thing only it can say — and\n // the frame carrying it is the one this adapter already reads.\n ...(typeof usage.cost === \"number\" ? { costUsd: usage.cost } : {}),\n // `prompt_tokens` is the whole prompt and `cached_tokens` is counted inside\n // it — \"cached tokens present in the prompt\". `Usage` keeps the three input\n // counts disjoint, so the cached part comes out here rather than every\n // caller having to know which wire produced the number it is holding.\n ...(usage.prompt_tokens !== undefined\n ? { inputTokens: Math.max(usage.prompt_tokens - (usage.prompt_tokens_details?.cached_tokens ?? 0), 0) } : {}),\n ...(usage.completion_tokens !== undefined ? { outputTokens: usage.completion_tokens } : {}),\n ...(usage.prompt_tokens_details?.cached_tokens !== undefined\n ? { cacheReadTokens: usage.prompt_tokens_details.cached_tokens } : {}),\n});\n\nasync function httpError(response: Response): Promise<ModelError> {\n const body = await response.text().catch(() => \"\");\n const code: ModelError[\"code\"] =\n response.status === 401 || response.status === 403 ? \"auth\"\n : response.status === 429 ? \"rate-limit\"\n : response.status === 400 && /context|token/i.test(body) ? \"context-length\"\n : response.status >= 500 ? \"provider\"\n : \"failed\";\n return {\n code,\n message: `${response.status} ${response.statusText}${body ? `: ${body.slice(0, 400)}` : \"\"}`,\n retryable: code === \"rate-limit\" || code === \"provider\",\n };\n}\n\nfunction transportError(error: unknown, signal?: AbortSignal): ModelError {\n if (signal?.aborted) return { code: \"cancelled\", message: \"Generation cancelled\", retryable: false };\n return { code: \"provider\", message: error instanceof Error ? error.message : String(error), retryable: true };\n}\n\n/** Server-sent events, reassembled across chunk boundaries. */\nasync function* sseLines(body: ReadableStream<Uint8Array>): AsyncGenerator<string> {\n const decoder = new TextDecoder();\n let buffer = \"\";\n for await (const chunk of body as unknown as AsyncIterable<Uint8Array>) {\n buffer += decoder.decode(chunk, { stream: true });\n let newline = buffer.indexOf(\"\\n\");\n while (newline >= 0) {\n const line = buffer.slice(0, newline).trim();\n buffer = buffer.slice(newline + 1);\n if (line.startsWith(\"data:\")) yield line.slice(5).trim();\n newline = buffer.indexOf(\"\\n\");\n }\n }\n}\n\ninterface ChatUsage {\n prompt_tokens?: number;\n completion_tokens?: number;\n prompt_tokens_details?: { cached_tokens?: number };\n /**\n * What this generation was charged. OpenRouter sets it on every response and\n * calls it credits, whose base currency is US dollars; plain OpenAI and the\n * self-hosted endpoints on this wire do not send it at all, so it is absent\n * rather than zero there.\n */\n cost?: number;\n}\ninterface ChatFrame {\n model?: string;\n usage?: ChatUsage;\n choices?: readonly {\n finish_reason?: string | null;\n delta?: {\n content?: string;\n reasoning?: string;\n tool_calls?: readonly { index: number; id?: string; function?: { name?: string; arguments?: string } }[];\n };\n }[];\n}\n"]}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What any implementation of the model port must do.
|
|
3
|
+
*
|
|
4
|
+
* The port is one method, and almost everything it promises is about the shape
|
|
5
|
+
* of an answer rather than its content: exactly one result and nothing after it,
|
|
6
|
+
* a cancellation that arrives as a value, deltas that add up to the message,
|
|
7
|
+
* tool arguments that parse, counts that stay absent when nobody reported them.
|
|
8
|
+
* Those are the promises a run recovers on — and every one of them is invisible
|
|
9
|
+
* to a test that only asks a model a question and reads the reply.
|
|
10
|
+
*
|
|
11
|
+
* So the suite cannot supply requests and expect responses: what a provider says
|
|
12
|
+
* is the provider's, and two of the three implementations here reach one. What
|
|
13
|
+
* a subject is asked for instead is a model **primed to say a scripted thing** —
|
|
14
|
+
* the script in the port's own vocabulary, rendered by the subject onto whatever
|
|
15
|
+
* it actually speaks, which for a wire is a stream of frames and for a fake is a
|
|
16
|
+
* scripted response. A case then drives that model and holds the answer to the
|
|
17
|
+
* port, without ever naming a provider's fields.
|
|
18
|
+
*
|
|
19
|
+
* The same trick runs the other way. An adapter that decodes a response
|
|
20
|
+
* perfectly and never transmits the caller's tools is useless, and that half of
|
|
21
|
+
* an adapter is pure encoding — which is why a wire also hands back a
|
|
22
|
+
* `SentRequest`: its own translation of what went out, back into the port's
|
|
23
|
+
* vocabulary. The alternative was to assert on the raw body, which would make
|
|
24
|
+
* every case provider-specific and so not a shared contract at all.
|
|
25
|
+
*
|
|
26
|
+
* One request per model. Nothing in this port is a conversation — a `Model` is a
|
|
27
|
+
* value you call — so a case wanting a second answer asks for a second model,
|
|
28
|
+
* and no subject has to decide what a spent script means.
|
|
29
|
+
*
|
|
30
|
+
* Inert on purpose. Each case is a name and a function that throws, so the suite
|
|
31
|
+
* drags no test framework into the package:
|
|
32
|
+
*
|
|
33
|
+
* ```ts
|
|
34
|
+
* for (const item of defineModelConformance(subject)) test(item.name, item.run);
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
import type { Model, ToolCall, Usage } from "./model.js";
|
|
38
|
+
import type { JsonValue } from "../json.js";
|
|
39
|
+
/** One case: a name, and a function that throws when the contract is broken. */
|
|
40
|
+
export interface ConformanceCase {
|
|
41
|
+
name: string;
|
|
42
|
+
run(): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* What the provider should say, in the port's vocabulary.
|
|
46
|
+
*
|
|
47
|
+
* A subject renders each of these onto whatever it speaks. Two rendering rules
|
|
48
|
+
* carry cases of their own, so they are stated here rather than assumed:
|
|
49
|
+
*
|
|
50
|
+
* - **Deltas arrive one at a time.** A subject that hands the whole body over at
|
|
51
|
+
* once cannot be cancelled part way through, and the case that pulls one delta
|
|
52
|
+
* and then gives up would prove nothing.
|
|
53
|
+
* - **A wire behaves as `fetch` does about the signal.** An already-aborted
|
|
54
|
+
* request never reaches the provider, and a body dies when the caller gives
|
|
55
|
+
* up. A transport that ignores the signal lets an adapter that never forwarded
|
|
56
|
+
* it pass both cancellation cases.
|
|
57
|
+
*/
|
|
58
|
+
export type ModelScript =
|
|
59
|
+
/** An answer that arrives in pieces. */
|
|
60
|
+
{
|
|
61
|
+
kind: "text";
|
|
62
|
+
deltas: readonly string[];
|
|
63
|
+
}
|
|
64
|
+
/** An answer the model thought about first, aloud. */
|
|
65
|
+
| {
|
|
66
|
+
kind: "reasoning";
|
|
67
|
+
thoughts: readonly string[];
|
|
68
|
+
text: string;
|
|
69
|
+
}
|
|
70
|
+
/** A turn that asks for tools, with each call's arguments fragmented on the way. */
|
|
71
|
+
| {
|
|
72
|
+
kind: "tool-calls";
|
|
73
|
+
calls: readonly ToolCall[];
|
|
74
|
+
}
|
|
75
|
+
/** An answer the provider cut off at the output ceiling, mid-sentence or mid-call. */
|
|
76
|
+
| {
|
|
77
|
+
kind: "truncated";
|
|
78
|
+
text: string;
|
|
79
|
+
calls?: readonly ToolCall[];
|
|
80
|
+
}
|
|
81
|
+
/** An answer the provider declined to give. */
|
|
82
|
+
| {
|
|
83
|
+
kind: "refused";
|
|
84
|
+
}
|
|
85
|
+
/** An answer reporting exactly these counts, from exactly this model. */
|
|
86
|
+
| {
|
|
87
|
+
kind: "usage";
|
|
88
|
+
usage: Usage;
|
|
89
|
+
model: string;
|
|
90
|
+
}
|
|
91
|
+
/** An answer that says nothing about counts or about which model served it. */
|
|
92
|
+
| {
|
|
93
|
+
kind: "silent";
|
|
94
|
+
}
|
|
95
|
+
/** The provider refusing the request outright. Wires only. */
|
|
96
|
+
| {
|
|
97
|
+
kind: "status";
|
|
98
|
+
status: number;
|
|
99
|
+
body: string;
|
|
100
|
+
}
|
|
101
|
+
/** A body that delivers this much and then dies. Wires only. */
|
|
102
|
+
| {
|
|
103
|
+
kind: "cut";
|
|
104
|
+
text: string;
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* What one outgoing request carried, translated back out of the wire's own
|
|
108
|
+
* vocabulary by the subject. Absence is meaningful: an empty `toolNames`
|
|
109
|
+
* asserts the request advertised no tools, and a missing `effort` asserts it
|
|
110
|
+
* carried no reasoning control at all.
|
|
111
|
+
*/
|
|
112
|
+
export interface SentRequest {
|
|
113
|
+
/**
|
|
114
|
+
* Every text the request transmitted for the model to read, in order —
|
|
115
|
+
* instructions first, then the conversation, wherever this wire puts them.
|
|
116
|
+
* One list rather than a field per role, because the roles are exactly what
|
|
117
|
+
* the wires disagree about: a tool result is a message on one and a block
|
|
118
|
+
* inside the previous user turn on another, and what an adapter owes is that
|
|
119
|
+
* the words arrive in the order they were said.
|
|
120
|
+
*/
|
|
121
|
+
text: readonly string[];
|
|
122
|
+
/** The names of the tools the request advertised. */
|
|
123
|
+
toolNames: readonly string[];
|
|
124
|
+
/** The JSON Schema the request advertised for each advertised tool. */
|
|
125
|
+
toolSchemas: Readonly<Record<string, JsonValue>>;
|
|
126
|
+
/** Each tool result the request carried, paired with the call it answers. */
|
|
127
|
+
toolResults: readonly {
|
|
128
|
+
callId: string;
|
|
129
|
+
content: string;
|
|
130
|
+
}[];
|
|
131
|
+
/** Each tool call the request carried back, so the provider can pair the result with it. */
|
|
132
|
+
toolCalls: readonly ToolCall[];
|
|
133
|
+
/** Media types of the image and file content the request transmitted, in order. */
|
|
134
|
+
mediaTypes: readonly string[];
|
|
135
|
+
/** The output ceiling the request carried, or absent where it carried none. */
|
|
136
|
+
maxOutputTokens?: number;
|
|
137
|
+
/** The provider-native reasoning control the request carried, verbatim, or absent. */
|
|
138
|
+
effort?: JsonValue;
|
|
139
|
+
/** The sampling control the request carried, or absent. */
|
|
140
|
+
temperature?: number;
|
|
141
|
+
/** How many cache breakpoints the request marked. */
|
|
142
|
+
cacheMarks: number;
|
|
143
|
+
}
|
|
144
|
+
/** A model primed to answer one request, and — where there is a wire — that request. */
|
|
145
|
+
export interface Answering {
|
|
146
|
+
model: Model;
|
|
147
|
+
/** What the one request this model was given actually carried. Wires only. */
|
|
148
|
+
sent?(): SentRequest;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* What this implementation does with the three request options a provider may
|
|
152
|
+
* refuse, each of which the port says is honoured or honestly ignored.
|
|
153
|
+
*
|
|
154
|
+
* Declared rather than inferred from the request, because both answers look
|
|
155
|
+
* identical from outside and only one of them is a defect: dropping `temperature`
|
|
156
|
+
* is what the current Anthropic models require, and dropping it silently on a
|
|
157
|
+
* wire that would have accepted it is a caller's control going nowhere.
|
|
158
|
+
*/
|
|
159
|
+
export interface Wire {
|
|
160
|
+
/** Whether a level reaches the provider as its own depth control. */
|
|
161
|
+
effort: "sent" | "ignored";
|
|
162
|
+
/** Whether a sampling control reaches a provider that accepts one. */
|
|
163
|
+
temperature: "sent" | "ignored";
|
|
164
|
+
/** Whether `cacheAfter` reaches the provider as a breakpoint. */
|
|
165
|
+
cache: "sent" | "ignored";
|
|
166
|
+
}
|
|
167
|
+
export interface ModelUnderTest {
|
|
168
|
+
/**
|
|
169
|
+
* A model primed to answer one request with `script`.
|
|
170
|
+
*
|
|
171
|
+
* Called once per request, so nothing leaks between cases, and never with a
|
|
172
|
+
* script this subject's declarations say it cannot play.
|
|
173
|
+
*/
|
|
174
|
+
answering(script: ModelScript): Answering | Promise<Answering>;
|
|
175
|
+
/**
|
|
176
|
+
* The transport under this implementation, where there is one the subject can
|
|
177
|
+
* script and read back.
|
|
178
|
+
*
|
|
179
|
+
* `"none"` is a claim about the implementation, not a way out of the cases: a
|
|
180
|
+
* model answering from something it already holds has no status to return, no
|
|
181
|
+
* body to cut in half and no request to report — and those are the cases a
|
|
182
|
+
* provider adapter most needs, so one that declares `"none"` is untested where
|
|
183
|
+
* it matters.
|
|
184
|
+
*/
|
|
185
|
+
wire: Wire | "none";
|
|
186
|
+
/**
|
|
187
|
+
* Whether a tool call's arguments reach the caller as they arrive.
|
|
188
|
+
*
|
|
189
|
+
* Declared rather than inferred from an empty stream, because "streamed
|
|
190
|
+
* nothing" and "asked for no tools this turn" look the same from outside, and
|
|
191
|
+
* an application rendering a call as it is typed would find out in production.
|
|
192
|
+
*/
|
|
193
|
+
toolArguments: "streamed" | "whole";
|
|
194
|
+
/** Whether this implementation can carry a model's reasoning back as deltas. */
|
|
195
|
+
reasoning: "streamed" | "none";
|
|
196
|
+
/**
|
|
197
|
+
* Whether this wire states what a generation cost.
|
|
198
|
+
*
|
|
199
|
+
* A router does — it picks an upstream provider per request and adds its own
|
|
200
|
+
* margin, so what it charged is a thing only it can say, and no rate table an
|
|
201
|
+
* application keeps can reconstruct it. Most wires do not, and there the
|
|
202
|
+
* field stays absent rather than being estimated from one.
|
|
203
|
+
*
|
|
204
|
+
* Declared both ways, because both are a promise: one that reports a cost has
|
|
205
|
+
* to carry it, and one that does not has to leave it alone rather than
|
|
206
|
+
* inventing a zero.
|
|
207
|
+
*/
|
|
208
|
+
cost: "reported" | "none";
|
|
209
|
+
}
|
|
210
|
+
export declare function defineModelConformance(subject: ModelUnderTest): readonly ConformanceCase[];
|