dsh-codex-oauth 0.2.8 → 0.3.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/CHANGELOG.md +26 -0
- package/README.md +80 -34
- package/README.zh.md +75 -42
- package/SPEC.md +39 -57
- package/bin/dsh-openai-codex.js +47 -22
- package/client/index.jsx +72 -24
- package/lib/adapter.js +128 -41
- package/lib/auth.js +230 -11
- package/lib/client.js +106 -21
- package/lib/index.js +9 -2
- package/lib/models.js +53 -0
- package/lib/openai-http.js +64 -0
- package/lib/quota-lookup.js +114 -0
- package/lib/quota-ui.js +24 -0
- package/lib/quota.js +111 -0
- package/lib/responses-request.js +125 -0
- package/lib/responses-stream.js +197 -0
- package/lib/safe-message.js +1 -1
- package/lib/store.js +82 -29
- package/lib/web-auth.js +59 -45
- package/package.json +8 -9
- package/scripts/patch-tui-quota-placement.mjs +35 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { LlmError, fileHandleText, offloadedImageText, requestImageHandleText, requiredImageOffload } from "@deepseek-ai/dsh-llm";
|
|
3
|
+
|
|
4
|
+
export const RESPONSES_REPLAY_PROTOCOL = "openai-responses-v1";
|
|
5
|
+
export function wireToolName(name) {
|
|
6
|
+
return /^[A-Za-z0-9_-]{1,64}$/.test(name) ? name : `dsh_${createHash("sha256").update(name).digest("hex").slice(0,32)}`;
|
|
7
|
+
}
|
|
8
|
+
function wireCallId(id) {
|
|
9
|
+
if (typeof id !== "string" || !id) throw new LlmError("Tool call ID must be a non-empty string", "INVALID_REQUEST");
|
|
10
|
+
// Older adapters store composite IDs. Hash the whole ID to keep calls with a shared prefix distinct.
|
|
11
|
+
return /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : `call_dsh_${createHash("sha256").update(id).digest("hex").slice(0,40)}`;
|
|
12
|
+
}
|
|
13
|
+
export function toolMap(tools = []) {
|
|
14
|
+
const names = new Map();
|
|
15
|
+
for (const tool of tools) {
|
|
16
|
+
const wire = wireToolName(tool.name);
|
|
17
|
+
if (names.has(wire)) throw new LlmError("Duplicate or conflicting tool names", "INVALID_REQUEST");
|
|
18
|
+
names.set(wire, tool.name);
|
|
19
|
+
}
|
|
20
|
+
return names;
|
|
21
|
+
}
|
|
22
|
+
export const blockFingerprint = (block) => createHash("sha256").update(JSON.stringify(block)).digest("hex");
|
|
23
|
+
|
|
24
|
+
function nativeReplay(block, metadata, binding, source) {
|
|
25
|
+
const state = source?.replayState;
|
|
26
|
+
if (state?.response?.protocol !== RESPONSES_REPLAY_PROTOCOL || state.response.clientId !== binding.clientId
|
|
27
|
+
|| source.provider !== "openai-codex" || source.model !== binding.model || state.response.model !== binding.model
|
|
28
|
+
|| metadata?.fingerprint !== blockFingerprint(block)) return undefined;
|
|
29
|
+
const item = metadata.item;
|
|
30
|
+
if (!item || typeof item !== "object" || !["message", "function_call", "reasoning"].includes(item.type)) return undefined;
|
|
31
|
+
if (block.type === "reasoning" && item.type !== "reasoning") return undefined;
|
|
32
|
+
if (block.type === "tool-call" && (item.type !== "function_call" || item.call_id !== String(block.id)
|
|
33
|
+
|| item.name !== wireToolName(block.name) || item.arguments !== block.arguments)) return undefined;
|
|
34
|
+
if (block.type === "text" && (item.type !== "message" || item.content?.length !== 1
|
|
35
|
+
|| (item.content[0].text ?? item.content[0].refusal) !== block.text)) return undefined;
|
|
36
|
+
return structuredClone(item);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function appendItem(input, item) {
|
|
40
|
+
const last = input.at(-1);
|
|
41
|
+
if (item.type === "message" && item.id && last?.type === "message" && last.id === item.id) last.content.push(...item.content);
|
|
42
|
+
else input.push(item);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function responsesRequest(options, binding, attachments, resolveAccess) {
|
|
46
|
+
// DSH's summarizer always supplies maxTokens. This route cannot enforce an
|
|
47
|
+
// output cap, so omit that internal budget rather than disabling compaction.
|
|
48
|
+
for (const key of ["temperature", "maxTokens", "stop"]) {
|
|
49
|
+
if (key === "maxTokens" && options.purpose === "compaction") continue;
|
|
50
|
+
if (options[key] !== undefined) throw new LlmError(`ChatGPT plan usage does not support ${key}; leave it unset`, "UNSUPPORTED_OPTION");
|
|
51
|
+
}
|
|
52
|
+
const names = toolMap(options.tools);
|
|
53
|
+
if (binding.info.reasoning?.efforts.length && options.reasoningEffort !== undefined
|
|
54
|
+
&& !binding.info.reasoning.efforts.some((level) => level.id === options.reasoningEffort)) throw new LlmError("The selected model does not advertise that reasoning effort", "UNSUPPORTED_OPTION");
|
|
55
|
+
const versions = new Map();
|
|
56
|
+
for (const message of options.messages) for (const block of message.content) {
|
|
57
|
+
if (block.type !== "image" || block.offloaded) continue;
|
|
58
|
+
if (!attachments) throw new LlmError("Image input requires the DSH attachment service", "UNSUPPORTED_CONTENT");
|
|
59
|
+
if (binding.info.inputModalities && !binding.info.inputModalities.includes("image")) throw new LlmError("The selected model does not accept images", "UNSUPPORTED_CONTENT");
|
|
60
|
+
const ref = block.attachment;
|
|
61
|
+
if (!versions.has(ref.attachmentId)) {
|
|
62
|
+
const scale = Math.min(1, Math.sqrt(4_194_304 / (ref.width * ref.height)));
|
|
63
|
+
versions.set(ref.attachmentId, await attachments.readImageRequest(ref, { width: Math.max(1, Math.floor(ref.width * scale)), height: Math.max(1, Math.floor(ref.height * scale)), maxBytes: 1_048_576 }, options.signal));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const offloadImages = requiredImageOffload(options.messages, { representation: "base64", maxBytes: 32 * 1024 * 1024 }, (block) => versions.get(block.attachment.attachmentId)?.bytes ?? 0);
|
|
67
|
+
if (offloadImages > 0) throw new LlmError("Request images exceed the adapter's 32 MiB base64 budget", "IMAGE_OFFLOAD_REQUIRED", { offloadImages });
|
|
68
|
+
const textContent = async (blocks) => {
|
|
69
|
+
const content = [];
|
|
70
|
+
for (const block of blocks) {
|
|
71
|
+
if (block.type === "text") content.push({ type: "input_text", text: block.text });
|
|
72
|
+
else if (block.type === "image") {
|
|
73
|
+
const access = resolveAccess?.(block.attachment);
|
|
74
|
+
if (block.offloaded) content.push({ type: "input_text", text: offloadedImageText(block.attachment, access) });
|
|
75
|
+
else {
|
|
76
|
+
const version = versions.get(block.attachment.attachmentId);
|
|
77
|
+
content.push({ type: "input_text", text: requestImageHandleText(block.attachment, version, access) });
|
|
78
|
+
content.push({ type: "input_image", image_url: `data:${version.mediaType};base64,${Buffer.from(version.data).toString("base64")}`, detail: "auto" });
|
|
79
|
+
}
|
|
80
|
+
} else if (block.type === "file") {
|
|
81
|
+
const access = attachments?.fileHostPath?.(block.attachment);
|
|
82
|
+
content.push({ type: "input_text", text: fileHandleText(block.attachment, access ? resolveAccess?.(block.attachment, access)?.readonlyPath : undefined) });
|
|
83
|
+
} else if (!["tool-addition", "tool-removal"].includes(block.type)) throw new LlmError(`Unsupported ${block.type} in request content`, "UNSUPPORTED_CONTENT");
|
|
84
|
+
}
|
|
85
|
+
return content;
|
|
86
|
+
};
|
|
87
|
+
const input = [];
|
|
88
|
+
let instructions = options.system;
|
|
89
|
+
const messages = options.messages;
|
|
90
|
+
for (const [messageIndex, message] of messages.entries()) {
|
|
91
|
+
options.signal?.throwIfAborted();
|
|
92
|
+
if (!["assistant", "tool", "system", "developer", "user"].includes(message.role)) throw new LlmError("Unsupported message role", "UNSUPPORTED_CONTENT");
|
|
93
|
+
if (message.role === "assistant") {
|
|
94
|
+
for (const [index, block] of message.content.entries()) {
|
|
95
|
+
const metadata = message.source?.replayState?.blocks?.[index];
|
|
96
|
+
const native = nativeReplay(block, metadata, binding, message.source);
|
|
97
|
+
if (native) {
|
|
98
|
+
if (native.type === "function_call") native.call_id = wireCallId(native.call_id);
|
|
99
|
+
appendItem(input, native); continue;
|
|
100
|
+
}
|
|
101
|
+
if (block.type === "text") input.push({ role: "assistant", content: [{ type: "output_text", text: block.text, annotations: [] }] });
|
|
102
|
+
else if (block.type === "tool-call") input.push({ type: "function_call", call_id: wireCallId(block.id), name: wireToolName(block.name), namespace: "dsh", arguments: block.arguments });
|
|
103
|
+
else if (block.type !== "reasoning") throw new LlmError(`Unsupported assistant ${block.type}`, "UNSUPPORTED_CONTENT");
|
|
104
|
+
}
|
|
105
|
+
} else if (message.role === "tool") {
|
|
106
|
+
input.push({ type: "function_call_output", call_id: wireCallId(message.source?.callId), output: await textContent(message.content) });
|
|
107
|
+
} else if (message.role === "system" && messageIndex === 0 && instructions === undefined) {
|
|
108
|
+
instructions = (await textContent(message.content)).map((part) => part.text ?? "").join("\n");
|
|
109
|
+
} else {
|
|
110
|
+
const content = await textContent(message.content);
|
|
111
|
+
if (content.length) input.push({ role: ["system", "developer"].includes(message.role) ? "developer" : "user", content });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
body: {
|
|
116
|
+
model: binding.model, input, store: false, stream: true,
|
|
117
|
+
...(instructions !== undefined ? { instructions } : {}),
|
|
118
|
+
...(options.reasoningEffort !== undefined ? { reasoning: { effort: String(options.reasoningEffort), summary: "auto" } } : {}),
|
|
119
|
+
...(names.size ? { tools: [{ type: "namespace", name: "dsh", description: "Tools executed by DeepSeek Harness", tools: options.tools.map((tool) => ({
|
|
120
|
+
type: "function", name: wireToolName(tool.name), description: tool.description, parameters: tool.parameters, strict: false,
|
|
121
|
+
})) }] } : {}),
|
|
122
|
+
},
|
|
123
|
+
names,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { LlmError } from "@deepseek-ai/dsh-llm";
|
|
2
|
+
import { responseError } from "./openai-http.js";
|
|
3
|
+
import { blockFingerprint, RESPONSES_REPLAY_PROTOCOL } from "./responses-request.js";
|
|
4
|
+
|
|
5
|
+
export async function* readSSE(body, signal, activity = () => {}) {
|
|
6
|
+
if (!body) throw new LlmError("OpenAI returned no response stream", "INVALID_RESPONSE");
|
|
7
|
+
const reader = body.getReader();
|
|
8
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
9
|
+
let buffer = "";
|
|
10
|
+
const cancel = () => { reader.cancel(signal.reason).catch(() => {}); };
|
|
11
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
12
|
+
function parse(packet) {
|
|
13
|
+
if (packet.length > 16 * 1024 * 1024) throw new LlmError("OpenAI stream event exceeds 16 MiB", "INVALID_RESPONSE");
|
|
14
|
+
const data = packet.split(/\r\n|\n|\r/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).replace(/^ /, "")).join("\n");
|
|
15
|
+
if (!data || data === "[DONE]") return undefined;
|
|
16
|
+
let event;
|
|
17
|
+
try { event = JSON.parse(data); } catch { throw new LlmError("OpenAI returned malformed stream data", "INVALID_RESPONSE"); }
|
|
18
|
+
if (!event || typeof event.type !== "string") throw new LlmError("OpenAI returned an invalid stream event", "INVALID_RESPONSE");
|
|
19
|
+
return event;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
while (true) {
|
|
23
|
+
signal?.throwIfAborted();
|
|
24
|
+
const { done, value } = await reader.read();
|
|
25
|
+
signal?.throwIfAborted();
|
|
26
|
+
if (done) { buffer += decoder.decode(); break; }
|
|
27
|
+
activity();
|
|
28
|
+
buffer += decoder.decode(value, { stream: true });
|
|
29
|
+
let match;
|
|
30
|
+
while ((match = /\r\n\r\n|\n\n|\r\r/.exec(buffer))) {
|
|
31
|
+
const packet = buffer.slice(0, match.index);
|
|
32
|
+
buffer = buffer.slice(match.index + match[0].length);
|
|
33
|
+
const event = parse(packet);
|
|
34
|
+
if (event) yield event;
|
|
35
|
+
}
|
|
36
|
+
if (buffer.length > 16 * 1024 * 1024) throw new LlmError("OpenAI stream event exceeds 16 MiB", "INVALID_RESPONSE");
|
|
37
|
+
}
|
|
38
|
+
const event = parse(buffer);
|
|
39
|
+
if (event) yield event;
|
|
40
|
+
} finally {
|
|
41
|
+
signal?.removeEventListener("abort", cancel);
|
|
42
|
+
await reader.cancel().catch(() => {});
|
|
43
|
+
reader.releaseLock();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function tokenUsage(usage) {
|
|
48
|
+
if (!usage) return undefined;
|
|
49
|
+
const input = usage.input_tokens, output = usage.output_tokens;
|
|
50
|
+
const cached = usage.input_tokens_details?.cached_tokens ?? 0;
|
|
51
|
+
if (![input, output, cached].every((value) => Number.isSafeInteger(value) && value >= 0) || cached > input) {
|
|
52
|
+
throw new LlmError("OpenAI returned invalid token usage", "INVALID_RESPONSE");
|
|
53
|
+
}
|
|
54
|
+
const reasoning = usage.output_tokens_details?.reasoning_tokens;
|
|
55
|
+
return { inputTokens: input - cached, outputTokens: output,
|
|
56
|
+
...(Number.isSafeInteger(usage.total_tokens) && usage.total_tokens === input + output ? { totalTokens: usage.total_tokens } : {}),
|
|
57
|
+
...(cached > 0 ? { cacheReadTokens: cached } : {}),
|
|
58
|
+
...(Number.isSafeInteger(reasoning) && reasoning >= 0 && reasoning <= output ? { reasoningTokens: reasoning } : {}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function* responsesStream(events, binding, names, headers) {
|
|
63
|
+
const items = new Map(), blocks = new Map(), order = [];
|
|
64
|
+
const invalid = (message) => { throw new LlmError(message, "INVALID_RESPONSE"); };
|
|
65
|
+
function* start(key, type, itemIndex, partIndex) {
|
|
66
|
+
if (blocks.has(key)) return blocks.get(key);
|
|
67
|
+
const block = { index: order.length, type, itemIndex, partIndex, text: "", arguments: "", closed: false };
|
|
68
|
+
blocks.set(key, block); order.push(block);
|
|
69
|
+
yield { type: "block-start", index: block.index, blockType: type };
|
|
70
|
+
return block;
|
|
71
|
+
}
|
|
72
|
+
function toolName(item) {
|
|
73
|
+
if (item.namespace && item.namespace !== "dsh") invalid("OpenAI returned a tool call in an unexpected namespace");
|
|
74
|
+
const name = names.get(item.name);
|
|
75
|
+
if (!name) invalid("OpenAI returned a tool call that was not offered");
|
|
76
|
+
return name;
|
|
77
|
+
}
|
|
78
|
+
function* added(item, index) {
|
|
79
|
+
items.set(index, item);
|
|
80
|
+
if (item.type === "reasoning") yield* start(`${index}:reasoning`, "reasoning", index);
|
|
81
|
+
else if (item.type === "function_call") {
|
|
82
|
+
if (typeof item.call_id !== "string" || !item.call_id) invalid("OpenAI returned a tool call without a call ID");
|
|
83
|
+
const block = yield* start(`${index}:function`, "tool-call", index);
|
|
84
|
+
block.id = item.call_id; block.name = toolName(item);
|
|
85
|
+
if (!block.announced) {
|
|
86
|
+
block.announced = true;
|
|
87
|
+
block.arguments = typeof item.arguments === "string" ? item.arguments : "";
|
|
88
|
+
yield { type: "tool-call-delta", index: block.index, id: block.id, name: block.name, argumentsDelta: block.arguments };
|
|
89
|
+
}
|
|
90
|
+
} else if (item.type !== "message") invalid(`Unsupported OpenAI output item: ${item.type}`);
|
|
91
|
+
}
|
|
92
|
+
function* text(key, type, itemIndex, partIndex, delta) {
|
|
93
|
+
if (typeof delta !== "string") invalid("OpenAI returned a non-text delta");
|
|
94
|
+
const block = yield* start(key, type, itemIndex, partIndex);
|
|
95
|
+
if (block.closed) invalid("OpenAI sent a delta after a completed block");
|
|
96
|
+
block.text += delta;
|
|
97
|
+
yield { type: type === "text" ? "text-delta" : "reasoning-delta", index: block.index, text: delta };
|
|
98
|
+
}
|
|
99
|
+
function* close(block, item, part) {
|
|
100
|
+
if (block.closed) return;
|
|
101
|
+
let content, native;
|
|
102
|
+
if (block.type === "tool-call") {
|
|
103
|
+
const args = item.arguments;
|
|
104
|
+
if (typeof args !== "string" || !args.startsWith(block.arguments)) invalid("OpenAI returned inconsistent tool-call arguments");
|
|
105
|
+
const rest = args.slice(block.arguments.length);
|
|
106
|
+
if (rest) yield { type: "tool-call-delta", index: block.index, id: block.id, argumentsDelta: rest };
|
|
107
|
+
try { const parsed = JSON.parse(args); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(); }
|
|
108
|
+
catch { invalid("OpenAI returned invalid tool-call arguments"); }
|
|
109
|
+
content = { type: "tool-call", id: block.id, name: block.name, arguments: args };
|
|
110
|
+
native = { ...item, namespace: "dsh" };
|
|
111
|
+
} else {
|
|
112
|
+
const final = block.type === "reasoning" ? (item.summary ?? []).map((part) => part.text ?? "").join("\n\n") : part?.text ?? part?.refusal ?? "";
|
|
113
|
+
if (!final.startsWith(block.text)) invalid("OpenAI returned inconsistent response text");
|
|
114
|
+
const rest = final.slice(block.text.length);
|
|
115
|
+
if (rest) yield { type: block.type === "text" ? "text-delta" : "reasoning-delta", index: block.index, text: rest };
|
|
116
|
+
content = { type: block.type, text: final };
|
|
117
|
+
native = block.type === "reasoning" ? item : { ...item, content: [part] };
|
|
118
|
+
}
|
|
119
|
+
block.closed = true; block.content = content;
|
|
120
|
+
block.replay = { fingerprint: blockFingerprint(content), item: structuredClone(native) };
|
|
121
|
+
yield { type: "block-end", index: block.index, block: content };
|
|
122
|
+
}
|
|
123
|
+
function* done(item, index) {
|
|
124
|
+
if (!items.has(index)) yield* added(item, index);
|
|
125
|
+
items.set(index, item);
|
|
126
|
+
if (item.type === "message") {
|
|
127
|
+
if (!Array.isArray(item.content)) invalid("OpenAI returned a message without content");
|
|
128
|
+
for (const [partIndex, part] of item.content.entries()) {
|
|
129
|
+
if (!["output_text", "refusal"].includes(part.type)) invalid(`Unsupported OpenAI message content: ${part.type}`);
|
|
130
|
+
const block = yield* start(`${index}:text:${partIndex}`, "text", index, partIndex);
|
|
131
|
+
yield* close(block, item, part);
|
|
132
|
+
}
|
|
133
|
+
} else {
|
|
134
|
+
const block = blocks.get(`${index}:${item.type === "reasoning" ? "reasoning" : "function"}`);
|
|
135
|
+
if (!block) invalid("OpenAI returned inconsistent output item types");
|
|
136
|
+
yield* close(block, item);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
for await (const event of events) {
|
|
140
|
+
const index = event.output_index;
|
|
141
|
+
if ((event.type.startsWith("response.output_item.") || ["response.content_part.added", "response.output_text.delta", "response.refusal.delta", "response.reasoning_summary_text.delta", "response.function_call_arguments.delta"].includes(event.type))
|
|
142
|
+
&& (!Number.isSafeInteger(index) || index < 0)) invalid("OpenAI returned an invalid output index");
|
|
143
|
+
if (["response.content_part.added", "response.output_text.delta", "response.refusal.delta"].includes(event.type)
|
|
144
|
+
&& (!Number.isSafeInteger(event.content_index) || event.content_index < 0)) invalid("OpenAI returned an invalid content index");
|
|
145
|
+
switch (event.type) {
|
|
146
|
+
case "response.output_item.added": yield* added(event.item, index); break;
|
|
147
|
+
case "response.output_item.done": yield* done(event.item, index); break;
|
|
148
|
+
case "response.content_part.added":
|
|
149
|
+
if (["output_text", "refusal"].includes(event.part?.type)) yield* start(`${index}:text:${event.content_index}`, "text", index, event.content_index);
|
|
150
|
+
break;
|
|
151
|
+
case "response.output_text.delta":
|
|
152
|
+
case "response.refusal.delta": yield* text(`${index}:text:${event.content_index}`, "text", index, event.content_index, event.delta); break;
|
|
153
|
+
case "response.reasoning_summary_text.delta": {
|
|
154
|
+
const block = yield* start(`${index}:reasoning`, "reasoning", index);
|
|
155
|
+
if (block.lastSummaryIndex !== undefined && block.lastSummaryIndex !== event.summary_index) yield* text(`${index}:reasoning`, "reasoning", index, undefined, "\n\n");
|
|
156
|
+
block.lastSummaryIndex = event.summary_index;
|
|
157
|
+
yield* text(`${index}:reasoning`, "reasoning", index, undefined, event.delta);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
case "response.function_call_arguments.delta": {
|
|
161
|
+
const block = blocks.get(`${index}:function`);
|
|
162
|
+
if (!block || block.closed || typeof event.delta !== "string") invalid("OpenAI returned an invalid tool-call delta");
|
|
163
|
+
block.arguments += event.delta;
|
|
164
|
+
yield { type: "tool-call-delta", index: block.index, id: block.id, argumentsDelta: event.delta };
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
case "response.failed": {
|
|
168
|
+
const usage = tokenUsage(event.response?.usage);
|
|
169
|
+
if (usage) yield { type: "usage", usage };
|
|
170
|
+
throw responseError({ error: event.response?.error }, undefined, headers);
|
|
171
|
+
}
|
|
172
|
+
case "error": throw responseError({ error: event }, undefined, headers);
|
|
173
|
+
case "response.incomplete": {
|
|
174
|
+
const usage = tokenUsage(event.response?.usage);
|
|
175
|
+
if (usage) yield { type: "usage", usage };
|
|
176
|
+
if (event.response?.incomplete_details?.reason !== "max_output_tokens") throw new LlmError("OpenAI response ended incomplete", "INCOMPLETE_RESPONSE", { requestId: headers?.get("x-request-id") ?? undefined });
|
|
177
|
+
yield { type: "finish", reason: { kind: "max-tokens" } };
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
case "response.completed": {
|
|
181
|
+
const response = event.response;
|
|
182
|
+
if (!response || (response.status && response.status !== "completed") || !Array.isArray(response.output)) invalid("OpenAI returned an invalid completed response");
|
|
183
|
+
for (const [itemIndex, item] of response.output.entries()) yield* done(item, itemIndex);
|
|
184
|
+
if (order.some((block) => !block.closed)) invalid("OpenAI completed a response with unfinished blocks");
|
|
185
|
+
const usage = tokenUsage(response.usage);
|
|
186
|
+
if (usage) yield { type: "usage", usage };
|
|
187
|
+
if (!order.some((block) => block.type === "tool-call" || (block.type === "text" && block.content.text.trim()))) throw new LlmError("OpenAI completed a response without visible content or tool calls", "EMPTY_RESPONSE");
|
|
188
|
+
yield { type: "finish", reason: { kind: order.some((block) => block.type === "tool-call") ? "tool-calls" : "stop" }, replayState: {
|
|
189
|
+
response: { protocol: RESPONSES_REPLAY_PROTOCOL, clientId: binding.clientId, model: binding.model, id: response.id },
|
|
190
|
+
blocks: order.map((block) => block.replay),
|
|
191
|
+
} };
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
throw new LlmError("OpenAI stream ended without a terminal response event", "TRANSPORT", { requestId: headers?.get("x-request-id") ?? undefined });
|
|
197
|
+
}
|
package/lib/safe-message.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export function safeMessage(error) {
|
|
2
2
|
return String(error?.message ?? error)
|
|
3
3
|
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[redacted token]")
|
|
4
|
-
.replace(/([?&](?:code|token|refresh_token|access_token|id_token)=)[^&#\s]+/giu, "$1[redacted]")
|
|
4
|
+
.replace(/([?&](?:code|token|refresh_token|access_token|id_token|id_token_hint)=)[^&#\s]+/giu, "$1[redacted]")
|
|
5
5
|
.replace(/(["']?(?:access_token|refresh_token|id_token|authorization_code|code_verifier|token|code)["']?\s*[:=]\s*)("[^"]*"|'[^']*'|[^,}\s]+)/giu, "$1[redacted]")
|
|
6
6
|
.replace(/(\bBearer\s+)[^\s,]+/giu, "$1[redacted]")
|
|
7
7
|
.slice(0, 500);
|
package/lib/store.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { chmod, lstat, mkdir, readFile } from "node:fs/promises";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import { dirname, join, resolve } from "node:path";
|
|
4
5
|
import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
|
|
5
6
|
|
|
6
7
|
export const OPENAI_CODEX_PROVIDER = "openai-codex";
|
|
7
|
-
export const OPENAI_CODEX_AUTH_FILE = ".openai-codex-auth.json";
|
|
8
|
+
export const OPENAI_CODEX_AUTH_FILE = ".openai-codex-auth.v2.json";
|
|
9
|
+
const CREDENTIAL_LOCK_WAIT_MS = 60_000;
|
|
8
10
|
export function authPath(home = process.env.DSH_HOME || join(homedir(), ".dsh")) {
|
|
9
11
|
return resolve(join(home, OPENAI_CODEX_AUTH_FILE));
|
|
10
12
|
}
|
|
11
13
|
|
|
12
|
-
function clone(value) { return value === undefined ? undefined : structuredClone(value); }
|
|
13
14
|
async function privateDirectory(filename) {
|
|
14
15
|
const directory = dirname(filename);
|
|
15
16
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
@@ -27,47 +28,99 @@ async function ownerOnly(filename) {
|
|
|
27
28
|
} catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
28
29
|
}
|
|
29
30
|
}
|
|
30
|
-
function
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
31
|
+
function nonempty(value) { return typeof value === "string" && value.length > 0; }
|
|
32
|
+
function validate(value, filename) {
|
|
33
|
+
const invalid = () => { throw new Error(`${filename} has an invalid auth format`); };
|
|
34
|
+
if (value?.version !== 2 || !/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value.hostId) || !Array.isArray(value.registrations)
|
|
35
|
+
|| (value.generation !== undefined && (!Number.isSafeInteger(value.generation) || value.generation < 0))) invalid();
|
|
36
|
+
const ids = new Set();
|
|
37
|
+
for (const registration of value.registrations) {
|
|
38
|
+
if (!nonempty(registration.clientId) || registration.clientId === "dynamic_agent_client" || ids.has(registration.clientId)
|
|
39
|
+
|| !nonempty(registration.subject) || registration.issuer !== "https://auth.openai.com") invalid();
|
|
40
|
+
ids.add(registration.clientId);
|
|
41
|
+
if (registration.access !== undefined && (!nonempty(registration.access) || !nonempty(registration.idToken)
|
|
42
|
+
|| !Number.isFinite(registration.expires) || registration.expires <= 0 || !Array.isArray(registration.scopes)
|
|
43
|
+
|| registration.scopes.some((scope) => !nonempty(scope)))) invalid();
|
|
44
|
+
if (registration.refresh !== undefined && !nonempty(registration.refresh)) invalid();
|
|
45
|
+
}
|
|
46
|
+
if (value.activeClientId !== undefined && !ids.has(value.activeClientId)) invalid();
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function activeRegistration(document) {
|
|
51
|
+
return document?.registrations.find((registration) => registration.clientId === document.activeClientId);
|
|
52
|
+
}
|
|
53
|
+
export function clearTokens(registration) {
|
|
54
|
+
for (const key of ["access", "refresh", "idToken", "expires", "scopes", "earliestRefreshAt"]) delete registration[key];
|
|
39
55
|
}
|
|
40
56
|
|
|
41
57
|
export class CodexCredentialStore {
|
|
58
|
+
#listeners = new Set();
|
|
42
59
|
constructor(filename = authPath()) { this.filename = filename; }
|
|
43
|
-
async
|
|
60
|
+
async readDocument() {
|
|
44
61
|
await privateDirectory(this.filename);
|
|
45
62
|
await ownerOnly(this.filename);
|
|
46
|
-
try {
|
|
47
|
-
|
|
63
|
+
try {
|
|
64
|
+
const text = await readFile(this.filename, "utf8");
|
|
65
|
+
let document;
|
|
66
|
+
try { document = JSON.parse(text); } catch { throw new Error(`${this.filename} is not valid JSON`); }
|
|
67
|
+
return validate(document, this.filename);
|
|
68
|
+
} catch (error) { if (error?.code === "ENOENT") return undefined; throw error; }
|
|
48
69
|
}
|
|
49
|
-
async
|
|
50
|
-
async
|
|
51
|
-
|
|
52
|
-
|
|
70
|
+
async initialize() { return this.transaction((document) => structuredClone(document)); }
|
|
71
|
+
async read(providerId) {
|
|
72
|
+
if (providerId !== OPENAI_CODEX_PROVIDER) return undefined;
|
|
73
|
+
const registration = activeRegistration(await this.readDocument());
|
|
74
|
+
return registration?.access ? registration : undefined;
|
|
75
|
+
}
|
|
76
|
+
async list() { return await this.read(OPENAI_CODEX_PROVIDER) ? [{ providerId: OPENAI_CODEX_PROVIDER, type: "oauth" }] : []; }
|
|
77
|
+
subscribe(listener) { this.#listeners.add(listener); return () => this.#listeners.delete(listener); }
|
|
78
|
+
async transaction(fn) {
|
|
53
79
|
await privateDirectory(this.filename);
|
|
54
80
|
return withFileLock(this.filename, async () => {
|
|
55
|
-
const
|
|
56
|
-
const
|
|
81
|
+
const saved = await this.readDocument();
|
|
82
|
+
const document = saved ?? { version: 2, hostId: `urn:uuid:${randomUUID()}`, registrations: [] };
|
|
83
|
+
const before = JSON.stringify(document);
|
|
84
|
+
const result = await fn(document);
|
|
85
|
+
validate(document, this.filename);
|
|
86
|
+
if (!saved || before !== JSON.stringify(document)) {
|
|
87
|
+
await writeFileAtomic(this.filename, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, dirMode: 0o700 });
|
|
88
|
+
const registration = activeRegistration(document);
|
|
89
|
+
for (const listener of this.#listeners) {
|
|
90
|
+
try { listener({ clientId: document.activeClientId, authenticated: !!registration?.access }); } catch { /* Observers cannot undo a saved credential. */ }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return structuredClone(result);
|
|
94
|
+
}, { waitMs: CREDENTIAL_LOCK_WAIT_MS });
|
|
95
|
+
}
|
|
96
|
+
async modify(providerId, fn) {
|
|
97
|
+
if (providerId !== OPENAI_CODEX_PROVIDER) throw new Error(`credential store does not own ${providerId}`);
|
|
98
|
+
return this.transaction(async (document) => {
|
|
99
|
+
const current = activeRegistration(document);
|
|
100
|
+
const next = await fn(current ? structuredClone(current) : undefined);
|
|
57
101
|
if (next === undefined) return current;
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
102
|
+
const existing = document.registrations.find((registration) => registration.clientId === next.clientId);
|
|
103
|
+
if (existing && existing.subject !== next.subject) throw new Error("OAuth registration identity changed");
|
|
104
|
+
if (existing) Object.assign(existing, next);
|
|
105
|
+
else document.registrations.push(next);
|
|
106
|
+
if (document.activeClientId !== next.clientId) document.generation = (document.generation ?? 0) + 1;
|
|
107
|
+
document.activeClientId = next.clientId;
|
|
108
|
+
return next;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
async select(clientId) {
|
|
112
|
+
return this.transaction((document) => {
|
|
113
|
+
if (!document.registrations.some((registration) => registration.clientId === clientId)) throw new Error("Unknown ChatGPT account registration");
|
|
114
|
+
document.activeClientId = clientId;
|
|
115
|
+
document.generation = (document.generation ?? 0) + 1;
|
|
62
116
|
});
|
|
63
117
|
}
|
|
64
118
|
async delete(providerId) {
|
|
65
119
|
if (providerId !== OPENAI_CODEX_PROVIDER) return;
|
|
66
|
-
await
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
await rm(this.filename, { force: true });
|
|
120
|
+
await this.transaction((document) => {
|
|
121
|
+
const registration = activeRegistration(document);
|
|
122
|
+
if (registration) clearTokens(registration);
|
|
123
|
+
document.generation = (document.generation ?? 0) + 1;
|
|
71
124
|
});
|
|
72
125
|
}
|
|
73
126
|
}
|
package/lib/web-auth.js
CHANGED
|
@@ -1,59 +1,63 @@
|
|
|
1
|
-
import { login, logout, status } from "./auth.js";
|
|
1
|
+
import { accounts, login, logout, status } from "./auth.js";
|
|
2
2
|
import { safeMessage } from "./safe-message.js";
|
|
3
3
|
|
|
4
4
|
export const AUTH_STATUS_PATH = "/plugins/dsh-codex-oauth/auth/status";
|
|
5
5
|
export const AUTH_LOGIN_PATH = "/plugins/dsh-codex-oauth/auth/login";
|
|
6
6
|
export const AUTH_LOGOUT_PATH = "/plugins/dsh-codex-oauth/auth/logout";
|
|
7
|
+
export const AUTH_ACCOUNT_PATH = "/plugins/dsh-codex-oauth/auth/account";
|
|
8
|
+
export const AUTH_MODELS_PATH = "/plugins/dsh-codex-oauth/auth/models";
|
|
7
9
|
|
|
10
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
8
11
|
function trusted(req) {
|
|
9
12
|
const remote = req.socket.remoteAddress;
|
|
10
13
|
if (!["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(remote)) return false;
|
|
11
14
|
if (req.headers["sec-fetch-site"] === "cross-site") return false;
|
|
12
15
|
if (!req.headers.host) return false;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
try {
|
|
17
|
+
const target = new URL(`http://${req.headers.host}`);
|
|
18
|
+
if (!LOOPBACK_HOSTS.has(target.hostname)) return false;
|
|
19
|
+
if (!req.headers.origin) return true;
|
|
20
|
+
return new URL(req.headers.origin).host === target.host;
|
|
21
|
+
} catch { return false; }
|
|
16
22
|
}
|
|
17
23
|
function json(res, statusCode, value) {
|
|
18
24
|
res.writeHead(statusCode, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff" });
|
|
19
25
|
res.end(JSON.stringify(value));
|
|
20
26
|
}
|
|
21
27
|
|
|
22
|
-
export function registerWebAuth(ctx, store) {
|
|
23
|
-
let operation;
|
|
24
|
-
let cancellation;
|
|
25
|
-
let challenge;
|
|
28
|
+
export function registerWebAuth(ctx, store, options = {}) {
|
|
29
|
+
let operation, cancellation, challenge;
|
|
26
30
|
let state = { status: "signed-out" };
|
|
27
|
-
const
|
|
31
|
+
const snapshot = async () => {
|
|
28
32
|
const value = await status(store);
|
|
29
|
-
|
|
30
|
-
return state;
|
|
33
|
+
return { ...value, accounts: await accounts(store), quota: options.adapter?.quotaSnapshot ? await options.adapter.quotaSnapshot() : { available: false, buckets: [] }, status: value.authenticated ? value.planUsage ? "signed-in" : "plan-disabled" : "signed-out" };
|
|
31
34
|
};
|
|
32
|
-
const
|
|
35
|
+
const update = async () => { state = await snapshot(); return state; };
|
|
36
|
+
const start = (newAccount) => {
|
|
33
37
|
if (operation) return;
|
|
34
38
|
const controller = new AbortController();
|
|
35
39
|
cancellation = controller;
|
|
36
|
-
state = { status: "signing-in" };
|
|
40
|
+
state = { ...state, status: "signing-in" };
|
|
37
41
|
challenge = undefined;
|
|
38
42
|
operation = (async () => {
|
|
39
43
|
try {
|
|
40
44
|
await login(store, {
|
|
41
45
|
signal: controller.signal,
|
|
42
|
-
prompt: async (prompt) => prompt.type === "select"
|
|
43
|
-
? "browser"
|
|
44
|
-
: new Promise((_resolve, reject) => controller.signal.addEventListener("abort", () => reject(controller.signal.reason), { once: true })),
|
|
45
46
|
notify: (event) => {
|
|
46
47
|
if (event.type !== "auth_url") return;
|
|
47
48
|
try {
|
|
48
49
|
const url = new URL(event.url);
|
|
49
|
-
if (url.
|
|
50
|
+
if (url.origin !== "https://auth.openai.com") throw new Error("Unexpected OAuth endpoint");
|
|
51
|
+
// Account selection still works without sending a retained ID token to the UI.
|
|
52
|
+
url.searchParams.delete("id_token_hint");
|
|
50
53
|
challenge = { url: url.href };
|
|
51
54
|
} catch (error) { controller.abort(error); }
|
|
52
55
|
},
|
|
53
|
-
});
|
|
56
|
+
}, { ...options.auth, newAccount });
|
|
54
57
|
await update();
|
|
55
58
|
} catch (error) {
|
|
56
|
-
|
|
59
|
+
const current = await snapshot().catch(() => ({ authenticated: false }));
|
|
60
|
+
state = controller.signal.aborted ? current : { ...current, status: "error", message: safeMessage(error) };
|
|
57
61
|
} finally {
|
|
58
62
|
if (cancellation === controller) cancellation = undefined;
|
|
59
63
|
operation = undefined;
|
|
@@ -61,33 +65,43 @@ export function registerWebAuth(ctx, store) {
|
|
|
61
65
|
})();
|
|
62
66
|
};
|
|
63
67
|
ctx.inject(["webServer"], (web) => ctx.effect(() => {
|
|
68
|
+
const guarded = (method, handler) => async (req, res) => {
|
|
69
|
+
if (req.method !== method || !trusted(req)) return json(res, req.method === method ? 403 : 405, { error: "forbidden" });
|
|
70
|
+
try { await handler(req, res); } catch (error) { json(res, 500, { error: safeMessage(error) }); }
|
|
71
|
+
};
|
|
72
|
+
const query = (req) => new URL(req.url ?? "/", "http://127.0.0.1").searchParams;
|
|
64
73
|
const routes = [
|
|
65
|
-
web.webServer.register({ kind: "exact", path: AUTH_STATUS_PATH, handler: async (
|
|
66
|
-
if (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
74
|
+
web.webServer.register({ kind: "exact", path: AUTH_STATUS_PATH, handler: guarded("GET", async (_req, res) => {
|
|
75
|
+
if (!operation && state.status !== "error") await update();
|
|
76
|
+
json(res, 200, state);
|
|
77
|
+
}) }),
|
|
78
|
+
web.webServer.register({ kind: "exact", path: AUTH_LOGIN_PATH, handler: guarded("POST", async (req, res) => {
|
|
79
|
+
start(query(req).get("new_account") === "1");
|
|
80
|
+
for (let i = 0; i < 50 && !challenge && operation; i++) await new Promise((resolve) => setTimeout(resolve, 100));
|
|
81
|
+
if (!challenge) throw new Error("OAuth URL was not created");
|
|
82
|
+
json(res, 200, challenge);
|
|
83
|
+
}) }),
|
|
84
|
+
web.webServer.register({ kind: "exact", path: AUTH_LOGOUT_PATH, handler: guarded("POST", async (_req, res) => {
|
|
85
|
+
const signingIn = !!operation;
|
|
86
|
+
cancellation?.abort(new Error("sign-in cancelled"));
|
|
87
|
+
await operation?.catch(() => {});
|
|
88
|
+
// Cancelling a new sign-in must not revoke the previously active account.
|
|
89
|
+
const result = signingIn ? { remoteRevoked: true } : await logout(store, options.auth);
|
|
90
|
+
await update();
|
|
91
|
+
json(res, 200, { ok: true, ...result });
|
|
92
|
+
}) }),
|
|
93
|
+
web.webServer.register({ kind: "exact", path: AUTH_ACCOUNT_PATH, handler: guarded("POST", async (req, res) => {
|
|
94
|
+
cancellation?.abort(new Error("account changed"));
|
|
95
|
+
await operation?.catch(() => {});
|
|
96
|
+
await store.select(query(req).get("client_id"));
|
|
97
|
+
json(res, 200, await update());
|
|
98
|
+
}) }),
|
|
99
|
+
web.webServer.register({ kind: "exact", path: AUTH_MODELS_PATH, handler: guarded("POST", async (_req, res) => {
|
|
100
|
+
if (!options.adapter) throw new Error("Model adapter is unavailable");
|
|
101
|
+
const models = await options.adapter.listModels("openai-codex", { refresh: true });
|
|
102
|
+
options.adapter.options.onModelsChanged?.();
|
|
103
|
+
json(res, 200, { models });
|
|
104
|
+
}) }),
|
|
91
105
|
];
|
|
92
106
|
return async () => {
|
|
93
107
|
cancellation?.abort(new Error("plugin disposed"));
|