@herouucn/opencode-commandcode 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 +163 -0
- package/_version.txt +1 -0
- package/index.ts +32 -0
- package/manifest.json +34 -0
- package/models.json +2182 -0
- package/package.json +80 -0
- package/plugin.ts +249 -0
- package/src/auth.ts +43 -0
- package/src/catalog-break.ts +41 -0
- package/src/catalog.ts +769 -0
- package/src/convert.ts +242 -0
- package/src/costs-docs.ts +179 -0
- package/src/costs-models-dev.ts +173 -0
- package/src/manifest.ts +134 -0
- package/src/model.ts +168 -0
- package/src/startup.ts +43 -0
- package/src/stream.ts +237 -0
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { writeFileSync } from "fs";
|
|
2
|
+
|
|
3
|
+
export type CatalogStatus = "healthy" | "degraded" | "broken";
|
|
4
|
+
export type CostCatalogBest = "cli" | "docs" | "thirdParty" | "free" | "fallback" | "missing";
|
|
5
|
+
|
|
6
|
+
export type CostSources = {
|
|
7
|
+
cli: number;
|
|
8
|
+
officialDocs: number;
|
|
9
|
+
thirdParty: number;
|
|
10
|
+
free: number;
|
|
11
|
+
fallback: number;
|
|
12
|
+
unmatched: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type CatalogReview = {
|
|
16
|
+
thirdParty: string[];
|
|
17
|
+
free: string[];
|
|
18
|
+
unmatched: string[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type CatalogManifest = {
|
|
22
|
+
schemaVersion: 1;
|
|
23
|
+
generatedAt: string;
|
|
24
|
+
pluginVersion: string;
|
|
25
|
+
commandCodeVersion: string;
|
|
26
|
+
commandCodeTarball: string;
|
|
27
|
+
modelCount: number;
|
|
28
|
+
reasoningModelCount: number;
|
|
29
|
+
extraction: {
|
|
30
|
+
modelCatalog: "ok" | "failed";
|
|
31
|
+
costCatalog: CostCatalogBest;
|
|
32
|
+
costCatalogError: string | null;
|
|
33
|
+
};
|
|
34
|
+
costSources: CostSources;
|
|
35
|
+
review?: CatalogReview;
|
|
36
|
+
status: CatalogStatus;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type BuildManifestInput = {
|
|
40
|
+
pluginVersion: string;
|
|
41
|
+
commandCodeVersion: string;
|
|
42
|
+
commandCodeTarball: string;
|
|
43
|
+
modelCount: number;
|
|
44
|
+
reasoningModelCount: number;
|
|
45
|
+
modelCatalogOk: boolean;
|
|
46
|
+
costSources: CostSources;
|
|
47
|
+
review?: CatalogReview;
|
|
48
|
+
generatedAt: string;
|
|
49
|
+
costCatalogError?: string | null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function meetsModelCountFloor(modelCount: number, lastSuccessful: number | null): boolean {
|
|
53
|
+
const floor = Math.max(20, Math.floor((lastSuccessful ?? 20) * 0.5));
|
|
54
|
+
// ponytail: when there is no prior catalog, lastSuccessful is null and the floor is 20
|
|
55
|
+
return modelCount >= (lastSuccessful === null ? 20 : floor);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function bestCostCatalog(sources: CostSources): CostCatalogBest {
|
|
59
|
+
if (sources.cli > 0) return "cli";
|
|
60
|
+
if (sources.officialDocs > 0) return "docs";
|
|
61
|
+
if (sources.thirdParty > 0) return "thirdParty";
|
|
62
|
+
if (sources.free > 0) return "free";
|
|
63
|
+
if (sources.fallback > 0) return "fallback";
|
|
64
|
+
return "missing";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function catalogStatus(modelCatalogOk: boolean, sources: CostSources): CatalogStatus {
|
|
68
|
+
if (!modelCatalogOk) return "broken";
|
|
69
|
+
if (sources.unmatched > 0) return "degraded";
|
|
70
|
+
return "healthy";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function commandCodeTarballUrl(version: string): string {
|
|
74
|
+
return `https://registry.npmjs.org/command-code/-/command-code-${version}.tgz`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function buildManifest(input: BuildManifestInput): CatalogManifest {
|
|
78
|
+
const status = catalogStatus(input.modelCatalogOk, input.costSources);
|
|
79
|
+
return {
|
|
80
|
+
schemaVersion: 1,
|
|
81
|
+
generatedAt: input.generatedAt,
|
|
82
|
+
pluginVersion: input.pluginVersion,
|
|
83
|
+
commandCodeVersion: input.commandCodeVersion,
|
|
84
|
+
commandCodeTarball: input.commandCodeTarball,
|
|
85
|
+
modelCount: input.modelCount,
|
|
86
|
+
reasoningModelCount: input.reasoningModelCount,
|
|
87
|
+
extraction: {
|
|
88
|
+
modelCatalog: input.modelCatalogOk ? "ok" : "failed",
|
|
89
|
+
costCatalog: bestCostCatalog(input.costSources),
|
|
90
|
+
costCatalogError: input.costCatalogError ?? null,
|
|
91
|
+
},
|
|
92
|
+
costSources: { ...input.costSources },
|
|
93
|
+
...(input.review ? { review: input.review } : {}),
|
|
94
|
+
status,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function writeManifest(path: string, manifest: CatalogManifest): void {
|
|
99
|
+
writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`, "utf-8");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function lastSuccessfulModelCount(manifest: CatalogManifest | null): number | null {
|
|
103
|
+
if (!manifest) return null;
|
|
104
|
+
if (manifest.status === "broken") return null;
|
|
105
|
+
return manifest.modelCount;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function countCostSources(input: {
|
|
109
|
+
modelIds: string[];
|
|
110
|
+
cliIds: Set<string>;
|
|
111
|
+
officialDocIds: Set<string>;
|
|
112
|
+
thirdPartyIds: Set<string>;
|
|
113
|
+
freeIds: Set<string>;
|
|
114
|
+
fallbackIds?: Set<string>;
|
|
115
|
+
}): CostSources {
|
|
116
|
+
const fallbackIds = input.fallbackIds ?? new Set<string>();
|
|
117
|
+
const sources: CostSources = {
|
|
118
|
+
cli: 0,
|
|
119
|
+
officialDocs: 0,
|
|
120
|
+
thirdParty: 0,
|
|
121
|
+
free: 0,
|
|
122
|
+
fallback: 0,
|
|
123
|
+
unmatched: 0,
|
|
124
|
+
};
|
|
125
|
+
for (const id of input.modelIds) {
|
|
126
|
+
if (input.cliIds.has(id)) sources.cli++;
|
|
127
|
+
else if (input.officialDocIds.has(id)) sources.officialDocs++;
|
|
128
|
+
else if (input.thirdPartyIds.has(id)) sources.thirdParty++;
|
|
129
|
+
else if (input.freeIds.has(id)) sources.free++;
|
|
130
|
+
else if (fallbackIds.has(id)) sources.fallback++;
|
|
131
|
+
else sources.unmatched++;
|
|
132
|
+
}
|
|
133
|
+
return sources;
|
|
134
|
+
}
|
package/src/model.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
LanguageModelV3,
|
|
3
|
+
LanguageModelV3CallOptions,
|
|
4
|
+
LanguageModelV3StreamResult,
|
|
5
|
+
LanguageModelV3GenerateResult,
|
|
6
|
+
LanguageModelV3Content,
|
|
7
|
+
LanguageModelV3Usage,
|
|
8
|
+
LanguageModelV3FinishReason,
|
|
9
|
+
} from "@ai-sdk/provider";
|
|
10
|
+
import { buildRequest } from "./convert.js";
|
|
11
|
+
import { parseStreamEvents } from "./stream.js";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_BASE_URL = "https://api.commandcode.ai";
|
|
14
|
+
// x-command-code-version must match the Command Code CLI version for API compatibility
|
|
15
|
+
const CC_VERSION = "0.26.20";
|
|
16
|
+
|
|
17
|
+
export interface CommandCodeModelOptions {
|
|
18
|
+
apiKey: string;
|
|
19
|
+
baseURL?: string;
|
|
20
|
+
headers?: Record<string, string>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class CommandCodeLanguageModel implements LanguageModelV3 {
|
|
24
|
+
readonly specificationVersion = "v3" as const;
|
|
25
|
+
readonly provider = "commandcode";
|
|
26
|
+
readonly modelId: string;
|
|
27
|
+
supportedUrls: Record<string, RegExp[]> = {};
|
|
28
|
+
|
|
29
|
+
private opts: CommandCodeModelOptions;
|
|
30
|
+
|
|
31
|
+
constructor(modelId: string, opts: CommandCodeModelOptions) {
|
|
32
|
+
this.modelId = modelId;
|
|
33
|
+
this.opts = opts;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private get baseURL(): string {
|
|
37
|
+
return this.opts.baseURL ?? DEFAULT_BASE_URL;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private buildHeaders(): Record<string, string> {
|
|
41
|
+
return {
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
Authorization: `Bearer ${this.opts.apiKey}`,
|
|
44
|
+
"x-command-code-version": CC_VERSION,
|
|
45
|
+
"x-cli-environment": "production",
|
|
46
|
+
"x-project-slug": "opencode",
|
|
47
|
+
...this.opts.headers,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async doStream(options: LanguageModelV3CallOptions): Promise<LanguageModelV3StreamResult> {
|
|
52
|
+
const body = buildRequest(this.modelId, options);
|
|
53
|
+
const requestBody = JSON.stringify(body);
|
|
54
|
+
|
|
55
|
+
const controller = new AbortController();
|
|
56
|
+
const timeout = setTimeout(
|
|
57
|
+
() => controller.abort(new Error("Request timed out after 5 minutes")),
|
|
58
|
+
300_000,
|
|
59
|
+
);
|
|
60
|
+
const userSignal = options.abortSignal;
|
|
61
|
+
if (userSignal) {
|
|
62
|
+
const onAbort = () => controller.abort(userSignal.reason);
|
|
63
|
+
userSignal.addEventListener("abort", onAbort, { once: true });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetch(`${this.baseURL}/alpha/generate`, {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: this.buildHeaders(),
|
|
70
|
+
body: requestBody,
|
|
71
|
+
signal: controller.signal,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
const errorBody = await response.text().catch(() => "");
|
|
76
|
+
let errorMessage = `Command Code API error: ${response.status} ${response.statusText}`;
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(errorBody);
|
|
79
|
+
if (parsed.error?.message) errorMessage = parsed.error.message;
|
|
80
|
+
else if (parsed.message) errorMessage = parsed.message;
|
|
81
|
+
} catch {
|
|
82
|
+
// intentionally silent: error body is not JSON
|
|
83
|
+
}
|
|
84
|
+
throw new Error(`${errorMessage} [model=${this.modelId}]`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!response.body) {
|
|
88
|
+
throw new Error(`Command Code API returned no body [model=${this.modelId}]`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const responseHeaders: Record<string, string> = {};
|
|
92
|
+
response.headers.forEach((v, k) => {
|
|
93
|
+
responseHeaders[k] = v;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
stream: parseStreamEvents(response.body as ReadableStream<Uint8Array>),
|
|
98
|
+
request: { body: requestBody },
|
|
99
|
+
response: { headers: responseHeaders },
|
|
100
|
+
};
|
|
101
|
+
} finally {
|
|
102
|
+
clearTimeout(timeout);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async doGenerate(options: LanguageModelV3CallOptions): Promise<LanguageModelV3GenerateResult> {
|
|
107
|
+
const { stream } = await this.doStream(options);
|
|
108
|
+
|
|
109
|
+
const textParts: string[] = [];
|
|
110
|
+
const reasoningParts: string[] = [];
|
|
111
|
+
const content: LanguageModelV3Content[] = [];
|
|
112
|
+
let finishReason: LanguageModelV3FinishReason = { unified: "stop", raw: "stop" };
|
|
113
|
+
let usage: LanguageModelV3Usage = {
|
|
114
|
+
inputTokens: {
|
|
115
|
+
total: undefined,
|
|
116
|
+
noCache: undefined,
|
|
117
|
+
cacheRead: undefined,
|
|
118
|
+
cacheWrite: undefined,
|
|
119
|
+
},
|
|
120
|
+
outputTokens: { total: undefined, text: undefined, reasoning: undefined },
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const reader = stream.getReader();
|
|
124
|
+
try {
|
|
125
|
+
while (true) {
|
|
126
|
+
const { done, value } = await reader.read();
|
|
127
|
+
if (done) break;
|
|
128
|
+
|
|
129
|
+
switch (value.type) {
|
|
130
|
+
case "text-delta":
|
|
131
|
+
textParts.push(value.delta);
|
|
132
|
+
break;
|
|
133
|
+
case "reasoning-delta":
|
|
134
|
+
reasoningParts.push(value.delta);
|
|
135
|
+
break;
|
|
136
|
+
case "tool-call":
|
|
137
|
+
content.push({
|
|
138
|
+
type: "tool-call",
|
|
139
|
+
toolCallId: value.toolCallId,
|
|
140
|
+
toolName: value.toolName,
|
|
141
|
+
input: value.input,
|
|
142
|
+
});
|
|
143
|
+
break;
|
|
144
|
+
case "finish":
|
|
145
|
+
finishReason = value.finishReason;
|
|
146
|
+
usage = value.usage;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} finally {
|
|
151
|
+
reader.releaseLock();
|
|
152
|
+
stream.cancel();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const text = textParts.join("");
|
|
156
|
+
if (text) content.unshift({ type: "text", text });
|
|
157
|
+
|
|
158
|
+
const reasoning = reasoningParts.join("");
|
|
159
|
+
if (reasoning) content.unshift({ type: "reasoning", text: reasoning });
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
content,
|
|
163
|
+
finishReason,
|
|
164
|
+
usage,
|
|
165
|
+
warnings: [],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
package/src/startup.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import type { ModelEntry } from "./catalog.js";
|
|
5
|
+
|
|
6
|
+
export type { ModelEntry } from "./catalog.js";
|
|
7
|
+
|
|
8
|
+
export type StartupSummary = {
|
|
9
|
+
catalogSource: "bundled" | "cache" | "opt-in-local" | "remote";
|
|
10
|
+
commandCodeVersion: string | null;
|
|
11
|
+
modelCount: number;
|
|
12
|
+
reasoningModelCount: number;
|
|
13
|
+
degraded: boolean;
|
|
14
|
+
degradedReason: string | null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function pluginStateDir(): string {
|
|
18
|
+
const override = process.env.COMMANDCODE_PROVIDER_STATE_DIR?.trim();
|
|
19
|
+
if (override) return override;
|
|
20
|
+
return join(homedir(), ".local/state/opencode/commandcode-provider");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function readCatalogCache(dir = pluginStateDir()): ModelEntry[] | null {
|
|
24
|
+
const path = join(dir, "catalog-cache.json");
|
|
25
|
+
if (!existsSync(path)) return null;
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown;
|
|
28
|
+
if (!Array.isArray(parsed) || parsed.length === 0) return null;
|
|
29
|
+
return parsed as ModelEntry[];
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function writeCatalogCache(dir: string, models: ModelEntry[]): void {
|
|
36
|
+
mkdirSync(dir, { recursive: true });
|
|
37
|
+
writeFileSync(join(dir, "catalog-cache.json"), JSON.stringify(models) + "\n", "utf-8");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function writeStartupSummary(dir: string, summary: StartupSummary): void {
|
|
41
|
+
mkdirSync(dir, { recursive: true });
|
|
42
|
+
writeFileSync(join(dir, "startup.json"), JSON.stringify(summary) + "\n", "utf-8");
|
|
43
|
+
}
|
package/src/stream.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
LanguageModelV3StreamPart,
|
|
3
|
+
LanguageModelV3Usage,
|
|
4
|
+
LanguageModelV3FinishReason,
|
|
5
|
+
} from "@ai-sdk/provider";
|
|
6
|
+
|
|
7
|
+
type RawEvent = Record<string, unknown> & { type: string };
|
|
8
|
+
|
|
9
|
+
function mapFinishReason(raw: string): LanguageModelV3FinishReason["unified"] {
|
|
10
|
+
switch (raw) {
|
|
11
|
+
case "stop":
|
|
12
|
+
case "end_turn":
|
|
13
|
+
return "stop";
|
|
14
|
+
case "tool_calls":
|
|
15
|
+
case "tool-calls":
|
|
16
|
+
return "tool-calls";
|
|
17
|
+
case "length":
|
|
18
|
+
case "max_tokens":
|
|
19
|
+
case "max-tokens":
|
|
20
|
+
case "max_output_tokens":
|
|
21
|
+
return "length";
|
|
22
|
+
case "content_filter":
|
|
23
|
+
return "content-filter";
|
|
24
|
+
default:
|
|
25
|
+
return "other";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function mapUsage(raw: Record<string, unknown>): LanguageModelV3Usage {
|
|
30
|
+
const inputDetails = (raw.inputTokenDetails ?? raw.input_token_details ?? {}) as Record<
|
|
31
|
+
string,
|
|
32
|
+
unknown
|
|
33
|
+
>;
|
|
34
|
+
const outputDetails = (raw.outputTokenDetails ?? raw.output_token_details ?? {}) as Record<
|
|
35
|
+
string,
|
|
36
|
+
unknown
|
|
37
|
+
>;
|
|
38
|
+
return {
|
|
39
|
+
inputTokens: {
|
|
40
|
+
total:
|
|
41
|
+
typeof raw.inputTokens === "number"
|
|
42
|
+
? raw.inputTokens
|
|
43
|
+
: typeof raw.prompt_tokens === "number"
|
|
44
|
+
? raw.prompt_tokens
|
|
45
|
+
: undefined,
|
|
46
|
+
noCache:
|
|
47
|
+
typeof inputDetails.noCacheTokens === "number" ? inputDetails.noCacheTokens : undefined,
|
|
48
|
+
cacheRead:
|
|
49
|
+
typeof inputDetails.cacheReadTokens === "number" ? inputDetails.cacheReadTokens : undefined,
|
|
50
|
+
cacheWrite:
|
|
51
|
+
typeof inputDetails.cacheWriteTokens === "number"
|
|
52
|
+
? inputDetails.cacheWriteTokens
|
|
53
|
+
: undefined,
|
|
54
|
+
},
|
|
55
|
+
outputTokens: {
|
|
56
|
+
total:
|
|
57
|
+
typeof raw.outputTokens === "number"
|
|
58
|
+
? raw.outputTokens
|
|
59
|
+
: typeof raw.completion_tokens === "number"
|
|
60
|
+
? raw.completion_tokens
|
|
61
|
+
: undefined,
|
|
62
|
+
text: typeof outputDetails.textTokens === "number" ? outputDetails.textTokens : undefined,
|
|
63
|
+
reasoning:
|
|
64
|
+
typeof outputDetails.reasoningTokens === "number"
|
|
65
|
+
? outputDetails.reasoningTokens
|
|
66
|
+
: undefined,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function toStreamPart(event: RawEvent): LanguageModelV3StreamPart | null {
|
|
72
|
+
switch (event.type) {
|
|
73
|
+
case "start":
|
|
74
|
+
return { type: "stream-start", warnings: [] };
|
|
75
|
+
|
|
76
|
+
case "text-start":
|
|
77
|
+
return { type: "text-start", id: event.id as string };
|
|
78
|
+
case "text-delta":
|
|
79
|
+
return {
|
|
80
|
+
type: "text-delta",
|
|
81
|
+
id: event.id as string,
|
|
82
|
+
delta: (event.text ?? event.delta ?? "") as string,
|
|
83
|
+
};
|
|
84
|
+
case "text-end":
|
|
85
|
+
return { type: "text-end", id: event.id as string };
|
|
86
|
+
|
|
87
|
+
case "reasoning-start":
|
|
88
|
+
return { type: "reasoning-start", id: event.id as string };
|
|
89
|
+
case "reasoning-delta":
|
|
90
|
+
return {
|
|
91
|
+
type: "reasoning-delta",
|
|
92
|
+
id: event.id as string,
|
|
93
|
+
delta: (event.text ?? event.delta ?? "") as string,
|
|
94
|
+
};
|
|
95
|
+
case "reasoning-end":
|
|
96
|
+
return { type: "reasoning-end", id: event.id as string };
|
|
97
|
+
|
|
98
|
+
case "tool-input-start":
|
|
99
|
+
return {
|
|
100
|
+
type: "tool-input-start",
|
|
101
|
+
id: event.id as string,
|
|
102
|
+
toolName: event.toolName as string,
|
|
103
|
+
dynamic: event.dynamic as boolean | undefined,
|
|
104
|
+
};
|
|
105
|
+
case "tool-input-delta":
|
|
106
|
+
return {
|
|
107
|
+
type: "tool-input-delta",
|
|
108
|
+
id: event.id as string,
|
|
109
|
+
delta: (event.delta ?? "") as string,
|
|
110
|
+
};
|
|
111
|
+
case "tool-input-end":
|
|
112
|
+
return { type: "tool-input-end", id: event.id as string };
|
|
113
|
+
|
|
114
|
+
case "tool-call": {
|
|
115
|
+
const input = event.input ?? event.args ?? event.arguments;
|
|
116
|
+
return {
|
|
117
|
+
type: "tool-call",
|
|
118
|
+
toolCallId: (event.toolCallId ?? event.id ?? "") as string,
|
|
119
|
+
toolName: event.toolName as string,
|
|
120
|
+
input: typeof input === "string" ? input : JSON.stringify(input ?? {}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
case "finish-step": {
|
|
125
|
+
const usage = event.usage ?? event.totalUsage ?? {};
|
|
126
|
+
const rawReason = (event.finishReason ?? event.rawFinishReason ?? "stop") as string;
|
|
127
|
+
return {
|
|
128
|
+
type: "finish",
|
|
129
|
+
finishReason: { unified: mapFinishReason(rawReason), raw: rawReason },
|
|
130
|
+
usage: mapUsage(
|
|
131
|
+
typeof usage === "object" && usage !== null ? (usage as Record<string, unknown>) : {},
|
|
132
|
+
),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
case "finish":
|
|
137
|
+
return null;
|
|
138
|
+
|
|
139
|
+
case "response-metadata":
|
|
140
|
+
return {
|
|
141
|
+
type: "response-metadata",
|
|
142
|
+
id: event.id as string | undefined,
|
|
143
|
+
modelId: event.modelId as string | undefined,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
case "error":
|
|
147
|
+
return { type: "error", error: event.error ?? event.message ?? "Unknown error" };
|
|
148
|
+
|
|
149
|
+
default:
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Assumes line-delimited JSON over SSE: one JSON object per `data:` line.
|
|
155
|
+
// Multi-line `data:` fields are not supported — the buffer splits on `\n`.
|
|
156
|
+
export function parseStreamEvents(
|
|
157
|
+
body: ReadableStream<Uint8Array>,
|
|
158
|
+
): ReadableStream<LanguageModelV3StreamPart> {
|
|
159
|
+
const reader = body.getReader();
|
|
160
|
+
const decoder = new TextDecoder();
|
|
161
|
+
let buffer = "";
|
|
162
|
+
|
|
163
|
+
return new ReadableStream<LanguageModelV3StreamPart>({
|
|
164
|
+
async pull(controller) {
|
|
165
|
+
try {
|
|
166
|
+
while (true) {
|
|
167
|
+
const lines = buffer.split("\n");
|
|
168
|
+
// Strip trailing \r from Windows-style \r\n line endings
|
|
169
|
+
for (let i = 0; i < lines.length; i++) {
|
|
170
|
+
const line = lines[i];
|
|
171
|
+
if (line !== undefined && line.endsWith("\r")) lines[i] = line.slice(0, -1);
|
|
172
|
+
}
|
|
173
|
+
buffer = lines.pop() ?? "";
|
|
174
|
+
|
|
175
|
+
for (const line of lines) {
|
|
176
|
+
const trimmed = line.trim();
|
|
177
|
+
if (!trimmed || trimmed.startsWith(":") || trimmed === "[DONE]") continue;
|
|
178
|
+
|
|
179
|
+
let jsonStr = trimmed;
|
|
180
|
+
if (jsonStr.startsWith("data: ")) jsonStr = jsonStr.slice(6);
|
|
181
|
+
if (jsonStr.startsWith("data:")) jsonStr = jsonStr.slice(5);
|
|
182
|
+
if (!jsonStr || jsonStr === "[DONE]") continue;
|
|
183
|
+
|
|
184
|
+
let parsed: RawEvent;
|
|
185
|
+
try {
|
|
186
|
+
parsed = JSON.parse(jsonStr);
|
|
187
|
+
} catch {
|
|
188
|
+
// intentionally silent: skip malformed SSE lines
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (typeof parsed !== "object" || parsed === null || typeof parsed.type !== "string")
|
|
193
|
+
continue;
|
|
194
|
+
|
|
195
|
+
const part = toStreamPart(parsed);
|
|
196
|
+
if (part) controller.enqueue(part);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const { done, value } = await reader.read();
|
|
200
|
+
if (done) {
|
|
201
|
+
if (buffer.trim()) {
|
|
202
|
+
const trimmed = buffer.trim();
|
|
203
|
+
if (trimmed && trimmed !== "[DONE]" && !trimmed.startsWith(":")) {
|
|
204
|
+
let jsonStr = trimmed;
|
|
205
|
+
if (jsonStr.startsWith("data: ")) jsonStr = jsonStr.slice(6);
|
|
206
|
+
if (jsonStr.startsWith("data:")) jsonStr = jsonStr.slice(5);
|
|
207
|
+
try {
|
|
208
|
+
const parsed = JSON.parse(jsonStr);
|
|
209
|
+
if (
|
|
210
|
+
typeof parsed === "object" &&
|
|
211
|
+
parsed !== null &&
|
|
212
|
+
typeof parsed.type === "string"
|
|
213
|
+
) {
|
|
214
|
+
const part = toStreamPart(parsed);
|
|
215
|
+
if (part) controller.enqueue(part);
|
|
216
|
+
}
|
|
217
|
+
} catch {
|
|
218
|
+
// intentionally silent: skip malformed final buffer
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
controller.close();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
buffer += decoder.decode(value, { stream: true });
|
|
227
|
+
}
|
|
228
|
+
} catch (err) {
|
|
229
|
+
controller.enqueue({ type: "error", error: err });
|
|
230
|
+
controller.close();
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
cancel() {
|
|
234
|
+
reader.cancel();
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
}
|