@gentbajko/slopify 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 +63 -0
- package/dist/adapter-registry.js +68 -0
- package/dist/adapters/image/bytes.js +64 -0
- package/dist/adapters/image/fal.js +164 -0
- package/dist/adapters/image/openai.js +155 -0
- package/dist/adapters/image/replicate.js +183 -0
- package/dist/adapters/llm/claude-code.js +158 -0
- package/dist/adapters/llm/codex.js +126 -0
- package/dist/adapters/llm/openrouter.js +193 -0
- package/dist/adapters/llm/run-cli.js +134 -0
- package/dist/adapters/llm/sse-lines.js +46 -0
- package/dist/adapters/retry-after.js +21 -0
- package/dist/adapters/tts/cartesia.js +105 -0
- package/dist/adapters/tts/elevenlabs.js +107 -0
- package/dist/adapters/tts/openai.js +95 -0
- package/dist/edge/cli.js +36 -0
- package/dist/edge/events/hub.js +76 -0
- package/dist/edge/http/actions.js +125 -0
- package/dist/edge/http/app.js +72 -0
- package/dist/edge/http/entries.js +41 -0
- package/dist/edge/http/files.js +67 -0
- package/dist/edge/http/problem.js +65 -0
- package/dist/edge/http/projects.js +128 -0
- package/dist/edge/http/prompts.js +73 -0
- package/dist/edge/http/providers.js +62 -0
- package/dist/edge/http/settings.js +90 -0
- package/dist/edge/http/staging.js +104 -0
- package/dist/edge/http/telemetry.js +29 -0
- package/dist/edge/http/usage.js +19 -0
- package/dist/edge/open-browser.js +20 -0
- package/dist/kernel/clock.js +18 -0
- package/dist/kernel/config/index.js +32 -0
- package/dist/kernel/db/index.js +24 -0
- package/dist/kernel/db/migrate.js +59 -0
- package/dist/kernel/db/migrations/0001-init.sql +17 -0
- package/dist/kernel/db/tx.js +53 -0
- package/dist/kernel/events.js +1 -0
- package/dist/kernel/ids.js +4 -0
- package/dist/kernel/lock.js +73 -0
- package/dist/kernel/log.js +34 -0
- package/dist/kernel/paths.js +21 -0
- package/dist/kernel/pipeline.js +22 -0
- package/dist/kernel/ports/image.js +1 -0
- package/dist/kernel/ports/llm.js +1 -0
- package/dist/kernel/ports/model.js +34 -0
- package/dist/kernel/ports/registry.js +1 -0
- package/dist/kernel/ports/tts.js +1 -0
- package/dist/kernel/runner/attempt-repo.js +50 -0
- package/dist/kernel/runner/attempt.js +146 -0
- package/dist/kernel/runner/graph.js +82 -0
- package/dist/kernel/runner/index.js +205 -0
- package/dist/kernel/runner/piece-repo.js +58 -0
- package/dist/kernel/runner/providers.js +92 -0
- package/dist/kernel/version.js +14 -0
- package/dist/main.js +212 -0
- package/dist/slices/admission/model.js +6 -0
- package/dist/slices/admission/repo.js +178 -0
- package/dist/slices/admission/rules.js +201 -0
- package/dist/slices/admission/start.js +103 -0
- package/dist/slices/admission/substitute.js +80 -0
- package/dist/slices/article/continuation.js +81 -0
- package/dist/slices/article/plain.js +14 -0
- package/dist/slices/article/run.js +166 -0
- package/dist/slices/article/split.js +51 -0
- package/dist/slices/article/store.js +21 -0
- package/dist/slices/cancel/index.js +61 -0
- package/dist/slices/images/run.js +221 -0
- package/dist/slices/library/lint.js +53 -0
- package/dist/slices/library/model.js +13 -0
- package/dist/slices/library/repo.js +111 -0
- package/dist/slices/library/save.js +87 -0
- package/dist/slices/library/slots.js +86 -0
- package/dist/slices/narration/chunk.js +71 -0
- package/dist/slices/narration/concat.js +79 -0
- package/dist/slices/narration/run.js +280 -0
- package/dist/slices/reruns/cascade.js +66 -0
- package/dist/slices/reruns/index.js +269 -0
- package/dist/slices/research/planner.js +81 -0
- package/dist/slices/research/run.js +192 -0
- package/dist/slices/research/synthesis.js +48 -0
- package/dist/slices/settings/cli-status.js +37 -0
- package/dist/slices/settings/keys.js +55 -0
- package/dist/slices/settings/model.js +54 -0
- package/dist/slices/settings/playback.js +60 -0
- package/dist/slices/settings/readiness.js +21 -0
- package/dist/slices/settings/repo.js +59 -0
- package/dist/slices/settings/voices.js +48 -0
- package/dist/slices/storage/asset-name.js +16 -0
- package/dist/slices/storage/delete-project.js +35 -0
- package/dist/slices/storage/downloads.js +100 -0
- package/dist/slices/storage/layout.js +59 -0
- package/dist/slices/storage/model.js +20 -0
- package/dist/slices/storage/reconcile.js +102 -0
- package/dist/slices/storage/repo.js +104 -0
- package/dist/slices/storage/staging.js +224 -0
- package/dist/slices/telemetry/collector-client.js +51 -0
- package/dist/slices/telemetry/flush.js +111 -0
- package/dist/slices/telemetry/machine.js +33 -0
- package/dist/slices/telemetry/model.js +39 -0
- package/dist/slices/telemetry/record.js +41 -0
- package/dist/slices/telemetry/repo.js +73 -0
- package/dist/slices/telemetry/usage.js +82 -0
- package/dist/slices/thumbnail/by-llm.js +30 -0
- package/dist/slices/thumbnail/run.js +200 -0
- package/dist/slices/video/ffmpeg.js +230 -0
- package/dist/slices/video/plan.js +71 -0
- package/dist/slices/video/run.js +179 -0
- package/dist/web/app-icon.svg +1 -0
- package/dist/web/assets/barlow-condensed-latin-600-normal-BFJEwTuo.woff +0 -0
- package/dist/web/assets/barlow-condensed-latin-600-normal-DepVgxBB.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-latin-700-normal-Dmwat-ge.woff +0 -0
- package/dist/web/assets/barlow-condensed-latin-700-normal-v1xN8_Wq.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-latin-ext-600-normal-18ESti3H.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-latin-ext-600-normal-Clv9cIcR.woff +0 -0
- package/dist/web/assets/barlow-condensed-latin-ext-700-normal-BIHFfxf0.woff +0 -0
- package/dist/web/assets/barlow-condensed-latin-ext-700-normal-CwuXbfVR.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-vietnamese-600-normal-A5AYRdjN.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-vietnamese-600-normal-CNlPk46_.woff +0 -0
- package/dist/web/assets/barlow-condensed-vietnamese-700-normal-DYeBwlKR.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-vietnamese-700-normal-DhIzd8Tb.woff +0 -0
- package/dist/web/assets/barlow-latin-400-normal-fsAxiSwU.woff +0 -0
- package/dist/web/assets/barlow-latin-400-normal-qiz4-Cze.woff2 +0 -0
- package/dist/web/assets/barlow-latin-500-normal-BPAOfeC8.woff2 +0 -0
- package/dist/web/assets/barlow-latin-500-normal-C1h8hMer.woff +0 -0
- package/dist/web/assets/barlow-latin-600-normal-CNwfPWQD.woff +0 -0
- package/dist/web/assets/barlow-latin-600-normal-DILqtrty.woff2 +0 -0
- package/dist/web/assets/barlow-latin-700-normal-A9pxMQ4z.woff2 +0 -0
- package/dist/web/assets/barlow-latin-700-normal-__SGTsZ1.woff +0 -0
- package/dist/web/assets/barlow-latin-800-normal-BdVooDN4.woff +0 -0
- package/dist/web/assets/barlow-latin-800-normal-s1sAMnoV.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-400-normal-CvBsJvxq.woff +0 -0
- package/dist/web/assets/barlow-latin-ext-400-normal-HxX4XjxC.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-500-normal-CJPcKP2Q.woff +0 -0
- package/dist/web/assets/barlow-latin-ext-500-normal-DOaysfXq.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-600-normal-B8NK_A3D.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-600-normal-DMVRjfRT.woff +0 -0
- package/dist/web/assets/barlow-latin-ext-700-normal-BLuWmldJ.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-700-normal-CctuGmmz.woff +0 -0
- package/dist/web/assets/barlow-latin-ext-800-normal-BiucknKG.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-800-normal-D7I3yvUw.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-400-normal-BFeobeCK.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-400-normal-Dpl4UHAZ.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-500-normal-GNfB7rCE.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-500-normal-zTViEIzf.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-600-normal-CA_GiK2e.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-600-normal-DcjprdFV.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-700-normal-4Jt4k04K.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-700-normal-D6euyNzi.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-800-normal-Cl1Mc_Dv.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-800-normal-D0VWpbij.woff +0 -0
- package/dist/web/assets/index-BPqQnrpy.css +1 -0
- package/dist/web/assets/index-D_sWbKQi.js +81 -0
- package/dist/web/favicon.svg +1 -0
- package/dist/web/index.html +15 -0
- package/package.json +41 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { redact } from "../../kernel/log.js";
|
|
3
|
+
import { providerError } from "../../kernel/ports/model.js";
|
|
4
|
+
import { retryAfter } from "../retry-after.js";
|
|
5
|
+
import { downloadImage } from "./bytes.js";
|
|
6
|
+
// The HTTP gateway adapter for Replicate (01-architecture Module boundaries). `fetch`,
|
|
7
|
+
// the injected clock, and the downloader beside this file; no SDK. `replicate` the package
|
|
8
|
+
// exists and wraps exactly the two requests below, so 05-dependencies' rung 3 covers it.
|
|
9
|
+
export const replicateBase = "https://api.replicate.com/v1";
|
|
10
|
+
// `Prefer: wait` holds the connection open while the model runs, up to the 60 s ceiling
|
|
11
|
+
// Replicate documents. A model slower than that answers `starting` instead, and the
|
|
12
|
+
// prediction has to be polled - which is why this adapter takes a clock. The attempt
|
|
13
|
+
// wrapper's 300 s (`logic/09` §Q77) is what ends the polling, through the request signal.
|
|
14
|
+
export const replicateWaitSeconds = 60;
|
|
15
|
+
export const replicatePollMs = 2000;
|
|
16
|
+
// `logic/02` §Q15: the model dropdown is filled from what the provider offers. Replicate's
|
|
17
|
+
// model index lists every model on the platform, text and video among them, so the
|
|
18
|
+
// text-to-image shortlist is this adapter's own data and adding one is a line here.
|
|
19
|
+
// ceiling: each entry has to take `prompt`, `aspect_ratio` and `output_format`, which is
|
|
20
|
+
// the convention across Replicate's official image models but not a guarantee; a model
|
|
21
|
+
// that spells its inputs differently needs a per-model input map first.
|
|
22
|
+
export const replicateModels = [
|
|
23
|
+
{ id: "black-forest-labs/flux-1.1-pro", name: "FLUX 1.1 [pro]" },
|
|
24
|
+
{ id: "black-forest-labs/flux-dev", name: "FLUX.1 [dev]" },
|
|
25
|
+
{ id: "black-forest-labs/flux-schnell", name: "FLUX.1 [schnell]" },
|
|
26
|
+
];
|
|
27
|
+
// A wire payload is narrowed, never cast (01-architecture §Q10, §Q33). `output` is one
|
|
28
|
+
// URL on the models that make a single image and an array on the ones that can make
|
|
29
|
+
// several, so both shapes are read and the first is taken.
|
|
30
|
+
const prediction = z.object({
|
|
31
|
+
status: z.string(),
|
|
32
|
+
output: z.union([z.string(), z.array(z.string())]).nullish(),
|
|
33
|
+
error: z.string().nullish(),
|
|
34
|
+
urls: z.object({ get: z.string().optional() }).nullish(),
|
|
35
|
+
});
|
|
36
|
+
const errorBody = z.object({ detail: z.string().optional(), title: z.string().optional() });
|
|
37
|
+
// The two terminal states that are not success. `canceled` cannot happen here - nothing
|
|
38
|
+
// cancels a prediction this app made - but reading it as terminal beats polling forever.
|
|
39
|
+
const settled = ["succeeded", "failed", "canceled"];
|
|
40
|
+
export function replicateImage(deps) {
|
|
41
|
+
return {
|
|
42
|
+
id: "replicate",
|
|
43
|
+
models: () => Promise.resolve(replicateModels),
|
|
44
|
+
generate: async (req) => {
|
|
45
|
+
const response = await deps.fetch(`${replicateBase}/models/${req.model}/predictions`, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
signal: req.signal,
|
|
48
|
+
headers: {
|
|
49
|
+
...auth(deps),
|
|
50
|
+
"Content-Type": "application/json",
|
|
51
|
+
Prefer: `wait=${String(replicateWaitSeconds)}`,
|
|
52
|
+
},
|
|
53
|
+
body: JSON.stringify({
|
|
54
|
+
input: {
|
|
55
|
+
prompt: req.prompt,
|
|
56
|
+
// `logic/09` step 1: Replicate takes the aspect itself, so the closest
|
|
57
|
+
// supported size is the exact one.
|
|
58
|
+
aspect_ratio: req.aspect,
|
|
59
|
+
// The port stores a PNG or a JPEG and these models default to WebP.
|
|
60
|
+
// Nothing else is set: step 2 asks for the provider's own quality and style.
|
|
61
|
+
output_format: "png",
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
throw await failure(response);
|
|
67
|
+
}
|
|
68
|
+
const url = await settle(deps, parse(await response.text()), req.signal);
|
|
69
|
+
// The download is inside the attempt with the call that produced the link, so a
|
|
70
|
+
// link that 404s is a failed attempt rather than a broken file on the project.
|
|
71
|
+
return await downloadImage({
|
|
72
|
+
fetch: deps.fetch,
|
|
73
|
+
provider: "Replicate",
|
|
74
|
+
url,
|
|
75
|
+
signal: req.signal,
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
// `Prefer: wait` answers `starting` when the model outlived its 60 s, and Replicate's own
|
|
81
|
+
// guidance is to poll `urls.get` until the prediction settles.
|
|
82
|
+
async function settle(deps, first, signal) {
|
|
83
|
+
let current = first;
|
|
84
|
+
while (!settled.includes(current.status)) {
|
|
85
|
+
const next = current.urls?.get;
|
|
86
|
+
if (next === undefined) {
|
|
87
|
+
throw providerError({
|
|
88
|
+
kind: "other",
|
|
89
|
+
message: `Replicate left the prediction ${current.status} and named no address to read it from`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
await deps.clock.sleep(replicatePollMs, signal);
|
|
93
|
+
const response = await deps.fetch(next, { signal, headers: auth(deps) });
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
throw await failure(response);
|
|
96
|
+
}
|
|
97
|
+
current = parse(await response.text());
|
|
98
|
+
}
|
|
99
|
+
return outputOf(current);
|
|
100
|
+
}
|
|
101
|
+
function outputOf(current) {
|
|
102
|
+
if (current.status !== "succeeded") {
|
|
103
|
+
throw declined(current);
|
|
104
|
+
}
|
|
105
|
+
const output = current.output;
|
|
106
|
+
const url = typeof output === "string" ? output : output?.[0];
|
|
107
|
+
if (url === undefined || url === "") {
|
|
108
|
+
throw providerError({ kind: "other", message: "Replicate succeeded with no image" });
|
|
109
|
+
}
|
|
110
|
+
return url;
|
|
111
|
+
}
|
|
112
|
+
// `logic/09` §Q74: a content-policy refusal is the provider's final answer and is never
|
|
113
|
+
// retried. Replicate reports one as a settled prediction whose `error` says so, with the
|
|
114
|
+
// same 201 the successful call had, so the text is what tells them apart.
|
|
115
|
+
const refusalWords = /\b(nsfw|sensitive|safety|content polic|flagged|moderat)/i;
|
|
116
|
+
function declined(current) {
|
|
117
|
+
// The provider's own words, verbatim, through the same redactor the wrapper uses.
|
|
118
|
+
const message = redact(current.error ?? `the prediction ended ${current.status}`);
|
|
119
|
+
// ceiling: read off the sentence, because Replicate carries no machine-readable reason
|
|
120
|
+
// on a failed prediction. A phrase the list does not know is retried three more times
|
|
121
|
+
// and costs the user three more images; a structured field would settle it, and the
|
|
122
|
+
// upgrade is to read one when Replicate ships it.
|
|
123
|
+
return providerError({
|
|
124
|
+
kind: refusalWords.test(message) ? "refusal" : "other",
|
|
125
|
+
message: `Replicate answered: ${message}`,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
function auth(deps) {
|
|
129
|
+
// `logic/02` §Q13: an attempt that finds no key fails rather than calling anonymously.
|
|
130
|
+
// `missing_key` rather than `auth` because the same rule makes it terminal.
|
|
131
|
+
const key = deps.key();
|
|
132
|
+
if (key === undefined || key === "") {
|
|
133
|
+
throw providerError({ kind: "missing_key", message: "no Replicate key is stored" });
|
|
134
|
+
}
|
|
135
|
+
return { Authorization: `Bearer ${key}` };
|
|
136
|
+
}
|
|
137
|
+
// Only the adapter can read a vendor's status code, so only the adapter names the kind;
|
|
138
|
+
// the attempt wrapper maps it and nothing downstream classifies again (03-conventions).
|
|
139
|
+
function kindOf(status) {
|
|
140
|
+
if (status === 401 || status === 403) {
|
|
141
|
+
return "auth";
|
|
142
|
+
}
|
|
143
|
+
if (status === 429) {
|
|
144
|
+
return "rate_limit";
|
|
145
|
+
}
|
|
146
|
+
// ceiling: everything else is `other` and is retried, so a 402 with no credit left
|
|
147
|
+
// fails the same way four times over. A terminal kind for "this request will never
|
|
148
|
+
// work" would have to be added to the port's error contract first, which is not this
|
|
149
|
+
// adapter's to widen.
|
|
150
|
+
return "other";
|
|
151
|
+
}
|
|
152
|
+
async function failure(response) {
|
|
153
|
+
const text = await response.text().catch(() => "");
|
|
154
|
+
const parsed = errorBody.safeParse(safeJson(text));
|
|
155
|
+
// The provider's own words, verbatim, through the same redactor the wrapper uses: an
|
|
156
|
+
// error body is free to quote the key back and this is the first place it is held.
|
|
157
|
+
const detail = parsed.success ? (parsed.data.detail ?? parsed.data.title) : undefined;
|
|
158
|
+
const message = redact(detail ?? text.trim());
|
|
159
|
+
const retryAfterMs = retryAfter(response.headers.get("retry-after"));
|
|
160
|
+
return providerError({
|
|
161
|
+
kind: kindOf(response.status),
|
|
162
|
+
message: `Replicate answered ${String(response.status)}: ${message || response.statusText}`,
|
|
163
|
+
...(retryAfterMs === undefined ? {} : { retryAfterMs }),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
function parse(text) {
|
|
167
|
+
const parsed = prediction.safeParse(safeJson(text));
|
|
168
|
+
if (!parsed.success) {
|
|
169
|
+
throw providerError({
|
|
170
|
+
kind: "other",
|
|
171
|
+
message: "Replicate's answer was not in the shape this app can read",
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return parsed.data;
|
|
175
|
+
}
|
|
176
|
+
function safeJson(text) {
|
|
177
|
+
try {
|
|
178
|
+
return JSON.parse(text);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { redact } from "../../kernel/log.js";
|
|
3
|
+
import { providerError } from "../../kernel/ports/model.js";
|
|
4
|
+
import { cliEvent, cliShaped, endedWithout, promptOf } from "./run-cli.js";
|
|
5
|
+
import { lines } from "./sse-lines.js";
|
|
6
|
+
// The local-agent adapter for Claude Code (01-architecture Module boundaries): spawned
|
|
7
|
+
// non-interactively, authenticated by the CLI's own login, no key anywhere in this file.
|
|
8
|
+
// Readiness is not computed here - `adapters/**` may not import `slices/**`, and
|
|
9
|
+
// `slices/settings/cli-status.ts` already probes the binary per request (`logic/02`
|
|
10
|
+
// §Q135). The registry `main.ts` builds is where this adapter and that probe meet.
|
|
11
|
+
export const claudeCodeBinary = "claude";
|
|
12
|
+
// The CLI takes an alias for the latest model of a family (`claude --help`, 2.1.258).
|
|
13
|
+
// ceiling: a fixed list, because the CLI has no offline command that prints the models an
|
|
14
|
+
// account may use. A user whose plan carries a model not listed here cannot pick it;
|
|
15
|
+
// reading the list off the CLI is the upgrade when it can print one.
|
|
16
|
+
export const claudeCodeModels = [
|
|
17
|
+
{ id: "fable", name: "Claude Fable (latest)" },
|
|
18
|
+
{ id: "opus", name: "Claude Opus (latest)" },
|
|
19
|
+
{ id: "sonnet", name: "Claude Sonnet (latest)" },
|
|
20
|
+
{ id: "haiku", name: "Claude Haiku (latest)" },
|
|
21
|
+
];
|
|
22
|
+
// Measured on 2.1.258, not assumed: with the built-in tools left alone, a `-p` run of this
|
|
23
|
+
// CLI still reached for ToolSearch and WebFetch, and the machine's MCP servers were loaded
|
|
24
|
+
// into the session. Neither belongs in a content pipeline - the article stage must not
|
|
25
|
+
// quietly ground itself on the web, since `logic/06` §Q47 makes grounding an explicit ask,
|
|
26
|
+
// and no stage should be able to touch the disk. `--tools ""` empties the built-in set and
|
|
27
|
+
// `--strict-mcp-config` drops the user's MCP servers; the init event of a run with both
|
|
28
|
+
// reports `"tools":[]` and `"mcp_servers":[]`.
|
|
29
|
+
export function claudeCodeArgs(req) {
|
|
30
|
+
return [
|
|
31
|
+
"-p",
|
|
32
|
+
"--output-format",
|
|
33
|
+
"stream-json",
|
|
34
|
+
// stream-json output is refused without it.
|
|
35
|
+
"--verbose",
|
|
36
|
+
"--strict-mcp-config",
|
|
37
|
+
...(req.webSearch === true
|
|
38
|
+
? ["--tools", "WebSearch", "--allowedTools", "WebSearch"]
|
|
39
|
+
: ["--tools", ""]),
|
|
40
|
+
...(req.model === "" ? [] : ["--model", req.model]),
|
|
41
|
+
// Everything after `--` is the prompt, so a prompt opening with a dash is text and not
|
|
42
|
+
// a flag. It is one argv element: no shell sees it and nothing in it is expanded.
|
|
43
|
+
"--",
|
|
44
|
+
promptOf(req.messages),
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
const assistantEvent = z.object({
|
|
48
|
+
message: z.object({
|
|
49
|
+
content: z.array(z.object({ type: z.string(), text: z.string().optional() })),
|
|
50
|
+
}),
|
|
51
|
+
});
|
|
52
|
+
// The result event as 2.1.258 writes it. `subtype` stays "success" even for a failed run -
|
|
53
|
+
// a bad model name answers `{"subtype":"success","is_error":true,"api_error_status":404}` -
|
|
54
|
+
// so `is_error` is what decides, with `subtype` checked as well for a run that never
|
|
55
|
+
// reached the model at all.
|
|
56
|
+
const resultEvent = z.object({
|
|
57
|
+
subtype: z.string(),
|
|
58
|
+
is_error: z.boolean().optional(),
|
|
59
|
+
result: z.string().optional(),
|
|
60
|
+
stop_reason: z.string().nullish(),
|
|
61
|
+
api_error_status: z.number().nullish(),
|
|
62
|
+
modelUsage: z
|
|
63
|
+
.record(z.string(), z.object({ inputTokens: z.number(), outputTokens: z.number() }))
|
|
64
|
+
.optional(),
|
|
65
|
+
});
|
|
66
|
+
export function claudeCodeLlm(deps) {
|
|
67
|
+
const binary = deps.binary ?? claudeCodeBinary;
|
|
68
|
+
async function* complete(req) {
|
|
69
|
+
const run = deps.run(binary, claudeCodeArgs(req), req.signal);
|
|
70
|
+
try {
|
|
71
|
+
for await (const line of lines(run.stdout, req.signal)) {
|
|
72
|
+
if (line.trim() === "") {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const event = cliEvent(binary, line);
|
|
76
|
+
if (event.type === "assistant") {
|
|
77
|
+
for (const block of cliShaped(binary, assistantEvent, event.value).message.content) {
|
|
78
|
+
// A turn also carries `thinking` and `tool_use` blocks; only the prose is the
|
|
79
|
+
// stage's output.
|
|
80
|
+
if (block.type === "text" && block.text !== undefined && block.text !== "") {
|
|
81
|
+
yield { type: "delta", text: block.text };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (event.type !== "result") {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const result = cliShaped(binary, resultEvent, event.value);
|
|
90
|
+
if (result.is_error === true || result.subtype !== "success") {
|
|
91
|
+
throw providerError({
|
|
92
|
+
kind: kindOf(result.api_error_status ?? null),
|
|
93
|
+
message: redact(result.result ?? result.subtype),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
yield {
|
|
97
|
+
type: "done",
|
|
98
|
+
usage: usageOf(result.modelUsage),
|
|
99
|
+
finishReason: result.stop_reason ?? null,
|
|
100
|
+
};
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
// A cancelled stage killed the child; the reason the user's cancel carried is what
|
|
106
|
+
// the runner expects back, not whatever the half-closed pipe threw (`logic/13`).
|
|
107
|
+
req.signal.throwIfAborted();
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
// The consumer can also abandon the generator - a retry, a timeout - and an agent
|
|
112
|
+
// session left running would keep spending the user's subscription.
|
|
113
|
+
run.kill();
|
|
114
|
+
}
|
|
115
|
+
// A cancelled run ends its stream the same way an exhausted one does: the child was
|
|
116
|
+
// killed, so stdout simply stopped. `logic/13` §Q112 says an aborted call counts
|
|
117
|
+
// nothing, so it must not be reported as the provider failing.
|
|
118
|
+
req.signal.throwIfAborted();
|
|
119
|
+
// The stream ended with no result event at all.
|
|
120
|
+
throw providerError({
|
|
121
|
+
kind: "other",
|
|
122
|
+
message: endedWithout(binary, await run.ended, run.stderr()),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
id: "claude-code",
|
|
127
|
+
// The CLI emits whole assistant turns rather than token deltas, which is still enough
|
|
128
|
+
// for the idle timeout of `logic/01` §Q62 to see life on the stream.
|
|
129
|
+
// ceiling: `--include-partial-messages` would give per-token deltas for the streamed
|
|
130
|
+
// article of `logic/07` step 2; it is the upgrade when the page needs finer text.
|
|
131
|
+
capabilities: { streams: true, reportsUsage: true, webSearch: true },
|
|
132
|
+
models: () => Promise.resolve(claudeCodeModels),
|
|
133
|
+
complete,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
// The CLI reports the upstream status on the result event; nothing else about a local
|
|
137
|
+
// process says which failure this was.
|
|
138
|
+
function kindOf(status) {
|
|
139
|
+
if (status === 401 || status === 403) {
|
|
140
|
+
return "auth";
|
|
141
|
+
}
|
|
142
|
+
if (status === 429) {
|
|
143
|
+
return "rate_limit";
|
|
144
|
+
}
|
|
145
|
+
return "other";
|
|
146
|
+
}
|
|
147
|
+
// `modelUsage` is keyed by model id and a run may touch more than one - a fallback model,
|
|
148
|
+
// a sub-agent - so the counts are summed. An empty object means the CLI reported none.
|
|
149
|
+
function usageOf(usage) {
|
|
150
|
+
const entries = Object.values(usage ?? {});
|
|
151
|
+
if (entries.length === 0) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
inputTokens: entries.reduce((total, one) => total + one.inputTokens, 0),
|
|
156
|
+
outputTokens: entries.reduce((total, one) => total + one.outputTokens, 0),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { redact } from "../../kernel/log.js";
|
|
3
|
+
import { providerError } from "../../kernel/ports/model.js";
|
|
4
|
+
import { cliEvent, cliShaped, endedWithout, promptOf } from "./run-cli.js";
|
|
5
|
+
import { lines } from "./sse-lines.js";
|
|
6
|
+
// The local-agent adapter for the Codex CLI. Same shape as Claude Code's and a different
|
|
7
|
+
// vocabulary: Codex writes a JSONL thread of `thread.started`, `item.*` and `turn.*`
|
|
8
|
+
// events. No key here either - the CLI's own login authenticates it (`logic/02` §Q135).
|
|
9
|
+
export const codexBinary = "codex";
|
|
10
|
+
// ceiling: a fixed list. `codex` has no offline command that prints the models an account
|
|
11
|
+
// may use - `codex doctor` reports the install, not the catalogue, and the model refresh
|
|
12
|
+
// it does at start needs the login this list is meant to be readable without. These two
|
|
13
|
+
// are the ids this machine's `~/.codex/config.toml` names; reading the real catalogue is
|
|
14
|
+
// the upgrade when the CLI grows a command that prints it.
|
|
15
|
+
export const codexModels = [
|
|
16
|
+
{ id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" },
|
|
17
|
+
{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol" },
|
|
18
|
+
];
|
|
19
|
+
// `codex exec --help` (0.149.1) for the flags. `-c web_search=<mode>` is a TOML override,
|
|
20
|
+
// and the binary's own error names the modes: "unknown variant `bogus`, expected one of
|
|
21
|
+
// `disabled`, `cached`, `indexed`, `live`". `live` is the grounded mode `logic/06` asks
|
|
22
|
+
// for and `disabled` is what every other stage runs under, so no stage grounds itself by
|
|
23
|
+
// accident. `--ephemeral` keeps no session file, `--skip-git-repo-check` lets it run in
|
|
24
|
+
// the data directory, and the quotes in the value are part of the argv element because
|
|
25
|
+
// the override is parsed as TOML, where a bare `live` is not a string.
|
|
26
|
+
export function codexArgs(req) {
|
|
27
|
+
return [
|
|
28
|
+
"exec",
|
|
29
|
+
"--json",
|
|
30
|
+
"--ephemeral",
|
|
31
|
+
"--skip-git-repo-check",
|
|
32
|
+
"-c",
|
|
33
|
+
`web_search="${req.webSearch === true ? "live" : "disabled"}"`,
|
|
34
|
+
...(req.model === "" ? [] : ["-m", req.model]),
|
|
35
|
+
// The prompt is one argv element after `--`, so a leading dash is text, not a flag.
|
|
36
|
+
"--",
|
|
37
|
+
promptOf(req.messages),
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
const itemCompleted = z.object({
|
|
41
|
+
item: z.object({ type: z.string(), text: z.string().optional() }),
|
|
42
|
+
});
|
|
43
|
+
// The field names are the shipped binary's own: `TurnCompletedEvent` carries `usage`, and
|
|
44
|
+
// its counts are `input_tokens`, `cached_input_tokens`, `cache_write_input_tokens`,
|
|
45
|
+
// `output_tokens`, `reasoning_output_tokens`. Only the two the port has a home for are read.
|
|
46
|
+
const turnCompleted = z.object({
|
|
47
|
+
usage: z.object({ input_tokens: z.number(), output_tokens: z.number() }).nullish(),
|
|
48
|
+
});
|
|
49
|
+
const turnFailed = z.object({ error: z.object({ message: z.string() }) });
|
|
50
|
+
// The top-level `error` event carries its text in `message`, not in `error.message`.
|
|
51
|
+
const errorEvent = z.object({ message: z.string() });
|
|
52
|
+
export function codexLlm(deps) {
|
|
53
|
+
const binary = deps.binary ?? codexBinary;
|
|
54
|
+
async function* complete(req) {
|
|
55
|
+
const run = deps.run(binary, codexArgs(req), req.signal);
|
|
56
|
+
try {
|
|
57
|
+
for await (const line of lines(run.stdout, req.signal)) {
|
|
58
|
+
if (line.trim() === "") {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const event = cliEvent(binary, line);
|
|
62
|
+
if (event.type === "item.completed") {
|
|
63
|
+
const { item } = cliShaped(binary, itemCompleted, event.value);
|
|
64
|
+
// A turn also completes `reasoning`, `web_search`, `command_execution` and
|
|
65
|
+
// `error` items. The last of those is a warning, not a failure: this machine's
|
|
66
|
+
// codex opens every run with an `error` item saying it has no metadata for the
|
|
67
|
+
// configured model, then answers normally. Only `turn.failed` ends a turn.
|
|
68
|
+
if (item.type === "agent_message" && item.text !== undefined && item.text !== "") {
|
|
69
|
+
yield { type: "delta", text: item.text };
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (event.type === "turn.completed") {
|
|
74
|
+
const { usage } = cliShaped(binary, turnCompleted, event.value);
|
|
75
|
+
// ceiling: Codex reports no stop reason, so the continuation loop of
|
|
76
|
+
// `logic/07` §Q59 cannot tell a finished answer from one cut at the output
|
|
77
|
+
// limit for this provider. A `--output-schema` run would, at the cost of
|
|
78
|
+
// constraining every stage's answer.
|
|
79
|
+
yield { type: "done", usage: usageOf(usage), finishReason: null };
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (event.type === "turn.failed") {
|
|
83
|
+
throw providerError({
|
|
84
|
+
kind: "other",
|
|
85
|
+
message: redact(cliShaped(binary, turnFailed, event.value).error.message),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if (event.type === "error") {
|
|
89
|
+
throw providerError({
|
|
90
|
+
kind: "other",
|
|
91
|
+
message: redact(cliShaped(binary, errorEvent, event.value).message),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
req.signal.throwIfAborted();
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
run.kill();
|
|
102
|
+
}
|
|
103
|
+
// A cancelled run ends its stream the same way an exhausted one does: the child was
|
|
104
|
+
// killed, so stdout simply stopped. `logic/13` §Q112 says an aborted call counts
|
|
105
|
+
// nothing, so it must not be reported as the provider failing.
|
|
106
|
+
req.signal.throwIfAborted();
|
|
107
|
+
// The stream ended with neither a completed turn nor a failure.
|
|
108
|
+
throw providerError({
|
|
109
|
+
kind: "other",
|
|
110
|
+
message: endedWithout(binary, await run.ended, run.stderr()),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
id: "codex",
|
|
115
|
+
// ceiling: one `item.completed` per whole message rather than per token, same as the
|
|
116
|
+
// other CLI. Enough for the idle timeout of `logic/01` §Q62 to see life on the stream.
|
|
117
|
+
capabilities: { streams: true, reportsUsage: true, webSearch: true },
|
|
118
|
+
models: () => Promise.resolve(codexModels),
|
|
119
|
+
complete,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function usageOf(usage) {
|
|
123
|
+
return usage === null || usage === undefined
|
|
124
|
+
? null
|
|
125
|
+
: { inputTokens: usage.input_tokens, outputTokens: usage.output_tokens };
|
|
126
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { redact } from "../../kernel/log.js";
|
|
3
|
+
import { providerError } from "../../kernel/ports/model.js";
|
|
4
|
+
import { retryAfter } from "../retry-after.js";
|
|
5
|
+
import { sseData } from "./sse-lines.js";
|
|
6
|
+
// The HTTP gateway adapter (01-architecture Module boundaries). `fetch` plus the line
|
|
7
|
+
// reader beside this file, no SDK: 05-dependencies records global `fetch` at rung 3 and
|
|
8
|
+
// the reader at rung 6, so nothing here is worth a dependency.
|
|
9
|
+
export const openRouterBase = "https://openrouter.ai/api/v1";
|
|
10
|
+
// OpenRouter ranks apps by these two headers; they carry no user data.
|
|
11
|
+
const appHeaders = {
|
|
12
|
+
"HTTP-Referer": "https://slopify.stream",
|
|
13
|
+
"X-Title": "Slopify",
|
|
14
|
+
};
|
|
15
|
+
// A wire payload is narrowed, never cast: everything unlisted is dropped at the seam so
|
|
16
|
+
// no vendor shape can leak past this file (01-architecture §Q10, §Q33).
|
|
17
|
+
const modelList = z.object({
|
|
18
|
+
data: z.array(z.object({ id: z.string(), name: z.string().optional() })),
|
|
19
|
+
});
|
|
20
|
+
const errorBody = z.object({
|
|
21
|
+
error: z.object({ message: z.string(), code: z.union([z.number(), z.string()]).optional() }),
|
|
22
|
+
});
|
|
23
|
+
const streamChunk = z.object({
|
|
24
|
+
choices: z
|
|
25
|
+
.array(z.object({
|
|
26
|
+
delta: z.object({ content: z.string().nullish() }).optional(),
|
|
27
|
+
finish_reason: z.string().nullish(),
|
|
28
|
+
}))
|
|
29
|
+
.optional(),
|
|
30
|
+
usage: z.object({ prompt_tokens: z.number(), completion_tokens: z.number() }).nullish(),
|
|
31
|
+
error: z
|
|
32
|
+
.object({ message: z.string(), code: z.union([z.number(), z.string()]).optional() })
|
|
33
|
+
.optional(),
|
|
34
|
+
});
|
|
35
|
+
export function openRouterLlm(deps) {
|
|
36
|
+
async function* complete(req) {
|
|
37
|
+
const response = await deps.fetch(`${openRouterBase}/chat/completions`, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
signal: req.signal,
|
|
40
|
+
headers: { ...headers(deps.key()), "Content-Type": "application/json" },
|
|
41
|
+
body: JSON.stringify({
|
|
42
|
+
model: req.model,
|
|
43
|
+
messages: req.messages.map((message) => ({
|
|
44
|
+
role: message.role,
|
|
45
|
+
content: message.content,
|
|
46
|
+
})),
|
|
47
|
+
stream: true,
|
|
48
|
+
// The usage-accounting flag: without it the final chunk carries no token counts
|
|
49
|
+
// and the Usage page would have nothing to count (`logic/16`).
|
|
50
|
+
usage: { include: true },
|
|
51
|
+
// `logic/06` §Q47: web grounding is asked for explicitly. OpenRouter runs the
|
|
52
|
+
// search itself, so the plugin works whatever model was picked.
|
|
53
|
+
...(req.webSearch === true ? { plugins: [{ id: "web" }] } : {}),
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
throw await failure(response);
|
|
58
|
+
}
|
|
59
|
+
if (response.body === null) {
|
|
60
|
+
throw providerError({ kind: "other", message: "OpenRouter answered with no body" });
|
|
61
|
+
}
|
|
62
|
+
let usage = null;
|
|
63
|
+
let finishReason = null;
|
|
64
|
+
let complete = false;
|
|
65
|
+
for await (const data of sseData(response.body, req.signal)) {
|
|
66
|
+
if (data === "") {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (data === "[DONE]") {
|
|
70
|
+
complete = true;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
const chunk = parseChunk(data);
|
|
74
|
+
if (chunk.error !== undefined) {
|
|
75
|
+
// A stream that has already answered 200 reports a mid-flight failure in a data
|
|
76
|
+
// frame; without this the stage would store half an article as a success.
|
|
77
|
+
throw providerError({
|
|
78
|
+
kind: kindOf(statusOf(chunk.error.code)),
|
|
79
|
+
message: redact(chunk.error.message),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
const choice = chunk.choices?.[0];
|
|
83
|
+
const text = choice?.delta?.content;
|
|
84
|
+
if (text !== undefined && text !== null && text !== "") {
|
|
85
|
+
yield { type: "delta", text };
|
|
86
|
+
}
|
|
87
|
+
if (choice?.finish_reason !== undefined && choice.finish_reason !== null) {
|
|
88
|
+
// It arrives on the last choice chunk, one frame before the usage frame, so it is
|
|
89
|
+
// held rather than read off whichever chunk happens to be last (`logic/07` §Q59
|
|
90
|
+
// reads it to tell a finished article from a truncated one).
|
|
91
|
+
finishReason = choice.finish_reason;
|
|
92
|
+
}
|
|
93
|
+
if (chunk.usage !== undefined && chunk.usage !== null) {
|
|
94
|
+
usage = {
|
|
95
|
+
inputTokens: chunk.usage.prompt_tokens,
|
|
96
|
+
outputTokens: chunk.usage.completion_tokens,
|
|
97
|
+
};
|
|
98
|
+
complete = true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (!complete) {
|
|
102
|
+
// Neither `[DONE]` nor the usage frame arrived, so the connection dropped part-way.
|
|
103
|
+
// Saying so is what stops a truncated answer being stored as a whole one.
|
|
104
|
+
throw providerError({
|
|
105
|
+
kind: "other",
|
|
106
|
+
message: "OpenRouter's stream ended before the response was complete",
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
yield { type: "done", usage, finishReason };
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
id: "openrouter",
|
|
113
|
+
capabilities: { streams: true, reportsUsage: true, webSearch: true },
|
|
114
|
+
models: async () => {
|
|
115
|
+
const response = await deps.fetch(`${openRouterBase}/models`, {
|
|
116
|
+
headers: headers(deps.key()),
|
|
117
|
+
});
|
|
118
|
+
if (!response.ok) {
|
|
119
|
+
throw await failure(response);
|
|
120
|
+
}
|
|
121
|
+
const parsed = modelList.safeParse(await response.json());
|
|
122
|
+
if (!parsed.success) {
|
|
123
|
+
throw providerError({
|
|
124
|
+
kind: "other",
|
|
125
|
+
message: "OpenRouter's model list was not in the shape this app can read",
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return parsed.data.data.map((model) => ({ id: model.id, name: model.name ?? model.id }));
|
|
129
|
+
},
|
|
130
|
+
complete,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function headers(key) {
|
|
134
|
+
// `logic/02` §Q13: an attempt that finds no key fails rather than calling anonymously
|
|
135
|
+
// and being told off by the provider in words the user cannot act on. `missing_key`
|
|
136
|
+
// rather than `auth` because the same rule makes it terminal: there is nothing to
|
|
137
|
+
// retry until the user saves a key, and the wrapper is where that is decided.
|
|
138
|
+
if (key === undefined || key === "") {
|
|
139
|
+
throw providerError({ kind: "missing_key", message: "no OpenRouter key is stored" });
|
|
140
|
+
}
|
|
141
|
+
return { Authorization: `Bearer ${key}`, ...appHeaders };
|
|
142
|
+
}
|
|
143
|
+
// Only the adapter can read a vendor's status code, so only the adapter names the kind;
|
|
144
|
+
// the attempt wrapper maps it and nothing downstream classifies again (03-conventions).
|
|
145
|
+
function kindOf(status) {
|
|
146
|
+
if (status === 401 || status === 403) {
|
|
147
|
+
return "auth";
|
|
148
|
+
}
|
|
149
|
+
if (status === 429) {
|
|
150
|
+
return "rate_limit";
|
|
151
|
+
}
|
|
152
|
+
// ceiling: everything else is `other` and is retried. 400 and 402 will fail the same
|
|
153
|
+
// way four times over; a terminal kind for "this request will never work" would have to
|
|
154
|
+
// be added to the port's error contract first, which is not this adapter's to widen.
|
|
155
|
+
return "other";
|
|
156
|
+
}
|
|
157
|
+
async function failure(response) {
|
|
158
|
+
const text = await response.text().catch(() => "");
|
|
159
|
+
const parsed = errorBody.safeParse(safeJson(text));
|
|
160
|
+
// The provider's own words, verbatim, through the same redactor the wrapper uses: an
|
|
161
|
+
// error body is free to quote the key back and this is the first place it is held.
|
|
162
|
+
const message = redact(parsed.success ? parsed.data.error.message : text.trim() || response.statusText);
|
|
163
|
+
const retryAfterMs = retryAfter(response.headers.get("retry-after"));
|
|
164
|
+
return providerError({
|
|
165
|
+
kind: kindOf(response.status),
|
|
166
|
+
message: `OpenRouter answered ${response.status}: ${message}`,
|
|
167
|
+
...(retryAfterMs === undefined ? {} : { retryAfterMs }),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
function statusOf(code) {
|
|
171
|
+
return typeof code === "number" ? code : Number(code ?? 0);
|
|
172
|
+
}
|
|
173
|
+
function parseChunk(data) {
|
|
174
|
+
const parsed = streamChunk.safeParse(safeJson(data));
|
|
175
|
+
if (!parsed.success) {
|
|
176
|
+
// A frame that will not parse is a stream cut mid-line or a payload this app does not
|
|
177
|
+
// understand. Either way the answer is incomplete, and the text is not echoed back:
|
|
178
|
+
// a half-written frame is noise, and the wrapper stores whatever is thrown.
|
|
179
|
+
throw providerError({
|
|
180
|
+
kind: "other",
|
|
181
|
+
message: "OpenRouter sent a stream frame this app could not read",
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return parsed.data;
|
|
185
|
+
}
|
|
186
|
+
function safeJson(text) {
|
|
187
|
+
try {
|
|
188
|
+
return JSON.parse(text);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
}
|