@bitkyc08/opencodex 2.1.8 → 2.1.9
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/gui/dist/assets/{index-DalshCSi.js → index-DVvcVBD_.js} +1 -1
- package/gui/dist/assets/index-xJdeQjzZ.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +2 -1
- package/src/adapters/anthropic.ts +29 -6
- package/src/adapters/openai-responses.ts +12 -0
- package/src/bridge.ts +21 -1
- package/src/codex-account-label.ts +34 -0
- package/src/codex-account-lifecycle.ts +21 -0
- package/src/codex-account-runtime-state.ts +13 -0
- package/src/codex-account-store.ts +355 -0
- package/src/codex-account-usability.ts +10 -0
- package/src/codex-auth-api.ts +446 -0
- package/src/codex-auth-collision.ts +66 -0
- package/src/codex-auth-context.ts +136 -0
- package/src/codex-catalog.ts +8 -2
- package/src/codex-quota.ts +130 -0
- package/src/codex-routing.ts +382 -0
- package/src/codex-websocket-registry.ts +57 -0
- package/src/config.ts +86 -26
- package/src/debug.ts +5 -4
- package/src/oauth/chatgpt.ts +150 -0
- package/src/oauth/index.ts +35 -7
- package/src/oauth/store.ts +9 -5
- package/src/privacy.ts +11 -0
- package/src/router.ts +1 -1
- package/src/server.ts +360 -23
- package/src/types.ts +32 -0
- package/src/vision/describe.ts +7 -3
- package/src/vision/index.ts +7 -3
- package/src/web-search/executor.ts +8 -3
- package/src/web-search/index.ts +3 -1
- package/src/web-search/loop.ts +6 -5
- package/src/ws-bridge.ts +56 -10
- package/gui/dist/assets/index-dCS-lwCM.css +0 -1
package/src/vision/describe.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { OcxProviderConfig } from "../types";
|
|
|
2
2
|
import { FORWARD_HEADERS } from "../adapters/openai-responses";
|
|
3
3
|
import { signalWithTimeout } from "../abort";
|
|
4
4
|
import { parseSidecarSSE } from "../web-search/parse";
|
|
5
|
+
import type { SidecarOutcomeRecorder } from "../web-search/executor";
|
|
5
6
|
|
|
6
7
|
export interface VisionSettings {
|
|
7
8
|
model: string;
|
|
@@ -39,7 +40,7 @@ function validateImageUrl(url: string): string | null {
|
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
42
|
* Describe ONE image via a gpt vision model through the ChatGPT forward backend — the path that has
|
|
42
|
-
* native image input. Reuses
|
|
43
|
+
* native image input. Reuses selected forwarded OAuth headers. The user's own request text is
|
|
43
44
|
* passed as context so the description is focused. Never throws — returns `{error}` on failure.
|
|
44
45
|
*/
|
|
45
46
|
export async function describeImage(
|
|
@@ -47,9 +48,10 @@ export async function describeImage(
|
|
|
47
48
|
detail: string | undefined,
|
|
48
49
|
contextText: string,
|
|
49
50
|
forwardProvider: OcxProviderConfig,
|
|
50
|
-
|
|
51
|
+
selectedForwardHeaders: Headers,
|
|
51
52
|
settings: VisionSettings,
|
|
52
53
|
abortSignal?: AbortSignal,
|
|
54
|
+
recordOutcome?: SidecarOutcomeRecorder,
|
|
53
55
|
): Promise<DescribeOutcome> {
|
|
54
56
|
const invalid = validateImageUrl(imageUrl);
|
|
55
57
|
if (invalid) return { text: "", error: invalid };
|
|
@@ -57,7 +59,7 @@ export async function describeImage(
|
|
|
57
59
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
58
60
|
if (forwardProvider.headers) Object.assign(headers, forwardProvider.headers);
|
|
59
61
|
for (const h of FORWARD_HEADERS) {
|
|
60
|
-
const v =
|
|
62
|
+
const v = selectedForwardHeaders.get(h);
|
|
61
63
|
if (v) headers[h] = v;
|
|
62
64
|
}
|
|
63
65
|
const content: unknown[] = [];
|
|
@@ -86,6 +88,7 @@ export async function describeImage(
|
|
|
86
88
|
body: JSON.stringify(body),
|
|
87
89
|
signal: linkedSignal.signal,
|
|
88
90
|
});
|
|
91
|
+
recordOutcome?.(res.status);
|
|
89
92
|
if (!res.ok) {
|
|
90
93
|
const t = await res.text().catch(() => "");
|
|
91
94
|
return { text: "", error: `vision sidecar HTTP ${res.status}: ${t.slice(0, 200)}` };
|
|
@@ -96,6 +99,7 @@ export async function describeImage(
|
|
|
96
99
|
if (!parsed.text.trim() && parsed.error) return { text: "", error: parsed.error };
|
|
97
100
|
return { text: parsed.text };
|
|
98
101
|
} catch (e) {
|
|
102
|
+
recordOutcome?.(e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error");
|
|
99
103
|
return { text: "", error: e instanceof Error ? e.message : String(e) };
|
|
100
104
|
} finally {
|
|
101
105
|
linkedSignal.cleanup();
|
package/src/vision/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { OcxConfig, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent } from "../types";
|
|
2
2
|
import { modelInList } from "../types";
|
|
3
3
|
import { describeImage, type VisionSettings } from "./describe";
|
|
4
|
+
import type { CodexAuthContext } from "../codex-auth-context";
|
|
5
|
+
import type { SidecarOutcomeRecorder } from "../web-search/executor";
|
|
4
6
|
|
|
5
7
|
export { describeImage } from "./describe";
|
|
6
8
|
|
|
@@ -66,12 +68,13 @@ export function planVisionSidecar(
|
|
|
66
68
|
modelId: string,
|
|
67
69
|
parsed: OcxParsedRequest,
|
|
68
70
|
incomingHeaders: Headers,
|
|
71
|
+
authContext: CodexAuthContext = { kind: "main", accountId: null },
|
|
69
72
|
): VisionPlan | undefined {
|
|
70
73
|
if (!modelInList(provider.noVisionModels, modelId)) return undefined;
|
|
71
74
|
if (!messagesHaveImage(parsed)) return undefined;
|
|
72
75
|
const cfg = config.visionSidecar ?? {};
|
|
73
76
|
if (cfg.enabled === false) return undefined;
|
|
74
|
-
if (!incomingHeaders.get("authorization")) return undefined;
|
|
77
|
+
if (authContext.kind === "main" && !incomingHeaders.get("authorization")) return undefined;
|
|
75
78
|
const forwardProvider = findForwardProvider(config);
|
|
76
79
|
if (!forwardProvider) return undefined;
|
|
77
80
|
return {
|
|
@@ -105,9 +108,10 @@ function renderDescription(out: { text: string; error?: string }): OcxTextConten
|
|
|
105
108
|
export async function describeImagesInPlace(
|
|
106
109
|
parsed: OcxParsedRequest,
|
|
107
110
|
forwardProvider: OcxProviderConfig,
|
|
108
|
-
|
|
111
|
+
selectedForwardHeaders: Headers,
|
|
109
112
|
settings: VisionSettings,
|
|
110
113
|
abortSignal?: AbortSignal,
|
|
114
|
+
recordSidecarOutcome?: SidecarOutcomeRecorder,
|
|
111
115
|
): Promise<void> {
|
|
112
116
|
// 1. Gather every image part across messages, each with its own message's text as context.
|
|
113
117
|
const jobs: ImageJob[] = [];
|
|
@@ -130,7 +134,7 @@ export async function describeImagesInPlace(
|
|
|
130
134
|
|
|
131
135
|
// 2. Describe all images with bounded concurrency (order preserved).
|
|
132
136
|
const outcomes = await runBounded(jobs, VISION_CONCURRENCY, j =>
|
|
133
|
-
describeImage(j.imageUrl, j.detail, j.contextText, forwardProvider,
|
|
137
|
+
describeImage(j.imageUrl, j.detail, j.contextText, forwardProvider, selectedForwardHeaders, settings, abortSignal, recordSidecarOutcome));
|
|
134
138
|
|
|
135
139
|
// 3. Rebuild each message, replacing image parts with their descriptions in order.
|
|
136
140
|
let oi = 0;
|
|
@@ -2,6 +2,7 @@ import type { OcxProviderConfig } from "../types";
|
|
|
2
2
|
import { FORWARD_HEADERS } from "../adapters/openai-responses";
|
|
3
3
|
import { signalWithTimeout } from "../abort";
|
|
4
4
|
import { parseSidecarSSE, type WebSearchResult } from "./parse";
|
|
5
|
+
import type { CodexUpstreamOutcome } from "../codex-routing";
|
|
5
6
|
|
|
6
7
|
export interface SidecarSettings {
|
|
7
8
|
model: string;
|
|
@@ -24,10 +25,11 @@ const IMAGE_INSTRUCTION =
|
|
|
24
25
|
|
|
25
26
|
/** A search result, or an `error` string when the search couldn't run (surfaced as a tool result). */
|
|
26
27
|
export type SidecarOutcome = WebSearchResult & { error?: string };
|
|
28
|
+
export type SidecarOutcomeRecorder = (outcome: CodexUpstreamOutcome) => void;
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
31
|
* Execute ONE web search via the gpt-mini sidecar through the ChatGPT forward backend — the only path
|
|
30
|
-
* with a real server-side web_search. Reuses
|
|
32
|
+
* with a real server-side web_search. Reuses selected forwarded OAuth headers (the forward adapter
|
|
31
33
|
* has no key of its own), replays the hosted web_search tool config verbatim, and runs the mini at
|
|
32
34
|
* minimal reasoning. Never throws — returns `{error}` so the caller injects a graceful tool result.
|
|
33
35
|
*/
|
|
@@ -35,14 +37,15 @@ export async function runWebSearch(
|
|
|
35
37
|
query: string,
|
|
36
38
|
hostedTool: Record<string, unknown>,
|
|
37
39
|
forwardProvider: OcxProviderConfig,
|
|
38
|
-
|
|
40
|
+
selectedForwardHeaders: Headers,
|
|
39
41
|
settings: SidecarSettings,
|
|
40
42
|
abortSignal?: AbortSignal,
|
|
43
|
+
recordOutcome?: SidecarOutcomeRecorder,
|
|
41
44
|
): Promise<SidecarOutcome> {
|
|
42
45
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
43
46
|
if (forwardProvider.headers) Object.assign(headers, forwardProvider.headers);
|
|
44
47
|
for (const h of FORWARD_HEADERS) {
|
|
45
|
-
const v =
|
|
48
|
+
const v = selectedForwardHeaders.get(h);
|
|
46
49
|
if (v) headers[h] = v;
|
|
47
50
|
}
|
|
48
51
|
const body = {
|
|
@@ -67,12 +70,14 @@ export async function runWebSearch(
|
|
|
67
70
|
body: JSON.stringify(body),
|
|
68
71
|
signal: linkedSignal.signal,
|
|
69
72
|
});
|
|
73
|
+
recordOutcome?.(res.status);
|
|
70
74
|
if (!res.ok) {
|
|
71
75
|
const t = await res.text().catch(() => "");
|
|
72
76
|
return { text: "", sources: [], error: `sidecar HTTP ${res.status}: ${t.slice(0, 200)}` };
|
|
73
77
|
}
|
|
74
78
|
return await parseSidecarSSE(res);
|
|
75
79
|
} catch (e) {
|
|
80
|
+
recordOutcome?.(e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error");
|
|
76
81
|
return { text: "", sources: [], error: e instanceof Error ? e.message : String(e) };
|
|
77
82
|
} finally {
|
|
78
83
|
linkedSignal.cleanup();
|
package/src/web-search/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types";
|
|
2
2
|
import { modelInList } from "../types";
|
|
3
3
|
import type { SidecarSettings } from "./executor";
|
|
4
|
+
import type { CodexAuthContext } from "../codex-auth-context";
|
|
4
5
|
|
|
5
6
|
export { runWithWebSearch } from "./loop";
|
|
6
7
|
export { buildWebSearchTool, extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "./synthetic-tool";
|
|
@@ -40,11 +41,12 @@ export function planWebSearch(
|
|
|
40
41
|
incomingHeaders: Headers,
|
|
41
42
|
provider: OcxProviderConfig,
|
|
42
43
|
modelId: string,
|
|
44
|
+
authContext: CodexAuthContext = { kind: "main", accountId: null },
|
|
43
45
|
): SidecarPlan | undefined {
|
|
44
46
|
if (!parsed._webSearch || isPassthrough) return undefined;
|
|
45
47
|
const cfg = config.webSearchSidecar ?? {};
|
|
46
48
|
if (cfg.enabled === false) return undefined;
|
|
47
|
-
if (!incomingHeaders.get("authorization")) return undefined; // not logged into ChatGPT → sidecar can't run
|
|
49
|
+
if (authContext.kind === "main" && !incomingHeaders.get("authorization")) return undefined; // not logged into ChatGPT → sidecar can't run
|
|
48
50
|
const forwardProvider = findForwardProvider(config);
|
|
49
51
|
if (!forwardProvider) return undefined;
|
|
50
52
|
return {
|
package/src/web-search/loop.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { ProviderAdapter } from "../adapters/base";
|
|
|
2
2
|
import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig } from "../types";
|
|
3
3
|
import { namespacedToolName } from "../types";
|
|
4
4
|
import { bridgeToResponsesSSE } from "../bridge";
|
|
5
|
-
import { runWebSearch, type SidecarSettings } from "./executor";
|
|
5
|
+
import { runWebSearch, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor";
|
|
6
6
|
import { formatWebSearchResult } from "./format-result";
|
|
7
7
|
import { WEB_SEARCH_TOOL_NAME } from "./synthetic-tool";
|
|
8
8
|
|
|
@@ -92,11 +92,12 @@ export interface WebSearchLoopDeps {
|
|
|
92
92
|
adapter: ProviderAdapter;
|
|
93
93
|
forwardProvider: OcxProviderConfig;
|
|
94
94
|
hostedTool: Record<string, unknown>;
|
|
95
|
-
|
|
95
|
+
selectedForwardHeaders: Headers;
|
|
96
96
|
settings: SidecarSettings;
|
|
97
97
|
maxSearches: number;
|
|
98
98
|
forceEmptyResponseId?: boolean;
|
|
99
99
|
abortSignal?: AbortSignal;
|
|
100
|
+
recordSidecarOutcome?: SidecarOutcomeRecorder;
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
/**
|
|
@@ -106,7 +107,7 @@ export interface WebSearchLoopDeps {
|
|
|
106
107
|
* streamed Responses SSE. web_search calls are executed internally and never relayed to Codex.
|
|
107
108
|
*/
|
|
108
109
|
export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Response> {
|
|
109
|
-
const { parsed, adapter,
|
|
110
|
+
const { parsed, adapter, selectedForwardHeaders, forwardProvider, hostedTool, settings, maxSearches, abortSignal, recordSidecarOutcome } = deps;
|
|
110
111
|
if (!adapter.parseResponse) return jsonError(500, "web-search sidecar requires a non-streaming adapter");
|
|
111
112
|
|
|
112
113
|
const messages: OcxMessage[] = [...parsed.context.messages];
|
|
@@ -128,7 +129,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
128
129
|
...parsed, stream: false,
|
|
129
130
|
context: { ...parsed.context, messages, tools: forceAnswer ? toolsNoWebSearch : allTools },
|
|
130
131
|
};
|
|
131
|
-
const request = adapter.buildRequest(iterParsed, { headers:
|
|
132
|
+
const request = adapter.buildRequest(iterParsed, { headers: selectedForwardHeaders });
|
|
132
133
|
let resp: Response;
|
|
133
134
|
try {
|
|
134
135
|
resp = await fetch(request.url, {
|
|
@@ -166,7 +167,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
166
167
|
outcome = { text: "", sources: [], error: "the model called web_search with an empty query" };
|
|
167
168
|
searchesExecuted++;
|
|
168
169
|
} else {
|
|
169
|
-
outcome = await runWebSearch(call.query, hostedTool, forwardProvider,
|
|
170
|
+
outcome = await runWebSearch(call.query, hostedTool, forwardProvider, selectedForwardHeaders, settings, abortSignal, recordSidecarOutcome);
|
|
170
171
|
searchesExecuted++;
|
|
171
172
|
if (outcome.error) failedQueries.add(normalizeQuery(call.query));
|
|
172
173
|
}
|
package/src/ws-bridge.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { ServerWebSocket } from "bun";
|
|
2
2
|
import { FORWARD_HEADERS } from "./adapters/openai-responses";
|
|
3
|
+
import type { CodexAuthContext } from "./codex-auth-context";
|
|
4
|
+
import { headersForCodexAuthContext } from "./codex-auth-context";
|
|
5
|
+
import type { ResponsesTerminalStatus } from "./bridge";
|
|
3
6
|
|
|
4
7
|
const OPEN = 1;
|
|
5
|
-
|
|
8
|
+
type ResponsesTerminalReporter = (status: ResponsesTerminalStatus) => void;
|
|
6
9
|
const SAFE_RESPONSE_HEADER_EXACT = new Set([
|
|
7
10
|
"retry-after",
|
|
8
11
|
"x-request-id",
|
|
@@ -15,6 +18,7 @@ const SAFE_RESPONSE_HEADER_EXACT = new Set([
|
|
|
15
18
|
|
|
16
19
|
export interface WsData {
|
|
17
20
|
headers?: Headers; // selected inbound upgrade headers only; never store full cookies/handshake internals
|
|
21
|
+
authContext?: CodexAuthContext; // immutable account decision made at upgrade time
|
|
18
22
|
cancel?: () => void; // cancels the in-flight stream reader/fetch
|
|
19
23
|
turnId?: number; // monotonically increasing per socket; prevents stale frames after replacement turns
|
|
20
24
|
}
|
|
@@ -25,15 +29,26 @@ export class WsSendDroppedError extends Error {
|
|
|
25
29
|
}
|
|
26
30
|
}
|
|
27
31
|
|
|
28
|
-
export function selectForwardHeaders(
|
|
32
|
+
export function selectForwardHeaders(
|
|
33
|
+
headers: Headers,
|
|
34
|
+
codexOverride?: { accessToken: string; chatgptAccountId: string },
|
|
35
|
+
): Headers {
|
|
29
36
|
const selected = new Headers();
|
|
30
37
|
for (const name of FORWARD_HEADERS) {
|
|
31
38
|
const value = headers.get(name);
|
|
32
39
|
if (value) selected.set(name, value);
|
|
33
40
|
}
|
|
41
|
+
if (codexOverride) {
|
|
42
|
+
selected.set("authorization", `Bearer ${codexOverride.accessToken}`);
|
|
43
|
+
selected.set("chatgpt-account-id", codexOverride.chatgptAccountId);
|
|
44
|
+
}
|
|
34
45
|
return selected;
|
|
35
46
|
}
|
|
36
47
|
|
|
48
|
+
export function selectForwardHeadersForAuthContext(headers: Headers, ctx: CodexAuthContext): Headers {
|
|
49
|
+
return headersForCodexAuthContext(headers, ctx);
|
|
50
|
+
}
|
|
51
|
+
|
|
37
52
|
export function safeResponseHeaders(headers: Headers): Record<string, string> {
|
|
38
53
|
const out: Record<string, string> = {};
|
|
39
54
|
for (const [name, value] of headers) {
|
|
@@ -41,7 +56,7 @@ export function safeResponseHeaders(headers: Headers): Record<string, string> {
|
|
|
41
56
|
if (
|
|
42
57
|
SAFE_RESPONSE_HEADER_EXACT.has(lower) ||
|
|
43
58
|
lower.startsWith("x-ratelimit-") ||
|
|
44
|
-
/^x-codex(?:-[a-z0-9-]+)?-(primary|secondary)-(used-percent|window-minutes|reset-at)$/.test(lower) ||
|
|
59
|
+
/^x-codex(?:-[a-z0-9-]+)?-(primary|secondary|tertiary)-(used-percent|window-minutes|reset-at)$/.test(lower) ||
|
|
45
60
|
/^x-codex(?:-[a-z0-9-]+)?-limit-name$/.test(lower)
|
|
46
61
|
) {
|
|
47
62
|
out[lower] = value;
|
|
@@ -126,6 +141,19 @@ function payloadType(payload: string): string | null {
|
|
|
126
141
|
}
|
|
127
142
|
}
|
|
128
143
|
|
|
144
|
+
function terminalStatusFromType(type: string): ResponsesTerminalStatus | null {
|
|
145
|
+
switch (type) {
|
|
146
|
+
case "response.completed":
|
|
147
|
+
return "completed";
|
|
148
|
+
case "response.failed":
|
|
149
|
+
return "failed";
|
|
150
|
+
case "response.incomplete":
|
|
151
|
+
return "incomplete";
|
|
152
|
+
default:
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
129
157
|
function protocolError(message: string): Record<string, unknown> {
|
|
130
158
|
return {
|
|
131
159
|
type: "protocol_error",
|
|
@@ -141,11 +169,19 @@ function sendProtocolError(ws: ServerWebSocket<WsData>, status: number, message:
|
|
|
141
169
|
export async function pumpResponsesSseToWebSocket(
|
|
142
170
|
ws: ServerWebSocket<WsData>,
|
|
143
171
|
sseStream: ReadableStream<Uint8Array>,
|
|
144
|
-
options: { isCurrent?: () => boolean } = {},
|
|
172
|
+
options: { isCurrent?: () => boolean; onTerminal?: ResponsesTerminalReporter } = {},
|
|
145
173
|
): Promise<void> {
|
|
146
174
|
const reader = sseStream.getReader();
|
|
147
175
|
const isCurrent = options.isCurrent ?? (() => true);
|
|
176
|
+
let clientCancelled = false;
|
|
177
|
+
let terminalReported = false;
|
|
178
|
+
const reportTerminal = (status: ResponsesTerminalStatus) => {
|
|
179
|
+
if (terminalReported || clientCancelled || !isCurrent()) return;
|
|
180
|
+
terminalReported = true;
|
|
181
|
+
options.onTerminal?.(status);
|
|
182
|
+
};
|
|
148
183
|
const cancel = () => {
|
|
184
|
+
clientCancelled = true;
|
|
149
185
|
void reader.cancel().catch(() => {});
|
|
150
186
|
};
|
|
151
187
|
ws.data.cancel = cancel;
|
|
@@ -159,6 +195,7 @@ export async function pumpResponsesSseToWebSocket(
|
|
|
159
195
|
if (payload === "[DONE]") return false;
|
|
160
196
|
const type = payloadType(payload);
|
|
161
197
|
if (!type) {
|
|
198
|
+
reportTerminal("incomplete");
|
|
162
199
|
sendProtocolError(ws, 502, "Invalid JSON payload in upstream SSE frame");
|
|
163
200
|
terminalSeen = true;
|
|
164
201
|
void reader.cancel().catch(() => {});
|
|
@@ -166,7 +203,9 @@ export async function pumpResponsesSseToWebSocket(
|
|
|
166
203
|
}
|
|
167
204
|
if (terminalSeen) return true;
|
|
168
205
|
sendTextFrame(ws, payload);
|
|
169
|
-
|
|
206
|
+
const terminalStatus = terminalStatusFromType(type);
|
|
207
|
+
if (terminalStatus) {
|
|
208
|
+
reportTerminal(terminalStatus);
|
|
170
209
|
terminalSeen = true;
|
|
171
210
|
void reader.cancel().catch(() => {});
|
|
172
211
|
return true;
|
|
@@ -191,11 +230,13 @@ export async function pumpResponsesSseToWebSocket(
|
|
|
191
230
|
const payload = parseSseBlock(buffer);
|
|
192
231
|
if (payload) handlePayload(payload);
|
|
193
232
|
}
|
|
194
|
-
if (!terminalSeen && isCurrent()) {
|
|
233
|
+
if (!terminalSeen && isCurrent() && !clientCancelled) {
|
|
234
|
+
reportTerminal("incomplete");
|
|
195
235
|
sendProtocolError(ws, 502, "Upstream stream ended before response terminal event");
|
|
196
236
|
}
|
|
197
237
|
} catch (err) {
|
|
198
238
|
if (!terminalSeen && isCurrent() && ws.readyState === OPEN) {
|
|
239
|
+
if (!(err instanceof WsSendDroppedError)) reportTerminal("incomplete");
|
|
199
240
|
sendProtocolError(ws, 502, err instanceof Error ? err.message : String(err));
|
|
200
241
|
}
|
|
201
242
|
} finally {
|
|
@@ -206,6 +247,7 @@ export async function pumpResponsesSseToWebSocket(
|
|
|
206
247
|
export function sendResponsesJsonAsEvents(
|
|
207
248
|
ws: ServerWebSocket<WsData>,
|
|
208
249
|
response: Record<string, unknown>,
|
|
250
|
+
onTerminal?: ResponsesTerminalReporter,
|
|
209
251
|
): void {
|
|
210
252
|
const output = Array.isArray(response.output) ? response.output : [];
|
|
211
253
|
sendJsonFrame(ws, {
|
|
@@ -226,6 +268,7 @@ export function sendResponsesJsonAsEvents(
|
|
|
226
268
|
type: `response.${finalStatus}` as "response.completed" | "response.failed" | "response.incomplete",
|
|
227
269
|
response: { ...response, status: finalStatus },
|
|
228
270
|
});
|
|
271
|
+
onTerminal?.(finalStatus);
|
|
229
272
|
}
|
|
230
273
|
|
|
231
274
|
function errorPayloadFromText(text: string): Record<string, unknown> {
|
|
@@ -247,6 +290,7 @@ export async function sendResponseToWebSocket(
|
|
|
247
290
|
ws: ServerWebSocket<WsData>,
|
|
248
291
|
response: Response,
|
|
249
292
|
isCurrent: () => boolean,
|
|
293
|
+
options: { onTerminal?: ResponsesTerminalReporter } = {},
|
|
250
294
|
): Promise<void> {
|
|
251
295
|
if (!isCurrent()) {
|
|
252
296
|
await response.body?.cancel().catch(() => {});
|
|
@@ -262,6 +306,7 @@ export async function sendResponseToWebSocket(
|
|
|
262
306
|
|
|
263
307
|
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
264
308
|
if (!response.body) {
|
|
309
|
+
options.onTerminal?.("incomplete");
|
|
265
310
|
sendJsonFrame(ws, buildWsErrorFrame(502, {
|
|
266
311
|
type: "protocol_error",
|
|
267
312
|
code: "websocket_protocol_error",
|
|
@@ -271,7 +316,7 @@ export async function sendResponseToWebSocket(
|
|
|
271
316
|
}
|
|
272
317
|
|
|
273
318
|
if (contentType.includes("text/event-stream")) {
|
|
274
|
-
await pumpResponsesSseToWebSocket(ws, response.body, { isCurrent });
|
|
319
|
+
await pumpResponsesSseToWebSocket(ws, response.body, { isCurrent, onTerminal: options.onTerminal });
|
|
275
320
|
return;
|
|
276
321
|
}
|
|
277
322
|
|
|
@@ -279,7 +324,7 @@ export async function sendResponseToWebSocket(
|
|
|
279
324
|
const text = await response.text();
|
|
280
325
|
if (!isCurrent()) return;
|
|
281
326
|
const json = JSON.parse(text) as Record<string, unknown>;
|
|
282
|
-
sendResponsesJsonAsEvents(ws, json);
|
|
327
|
+
sendResponsesJsonAsEvents(ws, json, options.onTerminal);
|
|
283
328
|
return;
|
|
284
329
|
}
|
|
285
330
|
|
|
@@ -289,7 +334,7 @@ export async function sendResponseToWebSocket(
|
|
|
289
334
|
return;
|
|
290
335
|
}
|
|
291
336
|
if (looksLikeSse(prefix)) {
|
|
292
|
-
await pumpResponsesSseToWebSocket(ws, stream, { isCurrent });
|
|
337
|
+
await pumpResponsesSseToWebSocket(ws, stream, { isCurrent, onTerminal: options.onTerminal });
|
|
293
338
|
return;
|
|
294
339
|
}
|
|
295
340
|
|
|
@@ -298,10 +343,11 @@ export async function sendResponseToWebSocket(
|
|
|
298
343
|
const trimmed = text.trim();
|
|
299
344
|
if (trimmed.startsWith("{")) {
|
|
300
345
|
const json = JSON.parse(trimmed) as Record<string, unknown>;
|
|
301
|
-
sendResponsesJsonAsEvents(ws, json);
|
|
346
|
+
sendResponsesJsonAsEvents(ws, json, options.onTerminal);
|
|
302
347
|
return;
|
|
303
348
|
}
|
|
304
349
|
|
|
350
|
+
options.onTerminal?.("incomplete");
|
|
305
351
|
sendJsonFrame(ws, buildWsErrorFrame(502, {
|
|
306
352
|
type: "protocol_error",
|
|
307
353
|
code: "websocket_protocol_error",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
:root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light dark;--bg:var(--lightningcss-light,#f6f7f9)var(--lightningcss-dark,#0c0d11);--rail:var(--lightningcss-light,#fff)var(--lightningcss-dark,#101218);--surface:var(--lightningcss-light,#fff)var(--lightningcss-dark,#15171d);--raised:var(--lightningcss-light,#f1f2f5)var(--lightningcss-dark,#1c1f27);--raised-hover:var(--lightningcss-light,#e8eaee)var(--lightningcss-dark,#242833);--border:var(--lightningcss-light,#e2e4e9)var(--lightningcss-dark,#2a2e39);--border-soft:var(--lightningcss-light,#ededf1)var(--lightningcss-dark,#20242d);--hover:var(--lightningcss-light,#11131c09)var(--lightningcss-dark,#ffffff06);--text:var(--lightningcss-light,#16181d)var(--lightningcss-dark,#edeef2);--muted:var(--lightningcss-light,#5b6270)var(--lightningcss-dark,#a3a9b5);--faint:var(--lightningcss-light,#868d9b)var(--lightningcss-dark,#6b7280);--accent:var(--lightningcss-light,#4f46e5)var(--lightningcss-dark,#6366f1);--accent-hover:var(--lightningcss-light,#4338ca)var(--lightningcss-dark,#818cf8);--accent-ink:#fff;--accent-soft:var(--lightningcss-light,#4f46e51a)var(--lightningcss-dark,#6366f129);--accent-ring:var(--lightningcss-light,#4f46e566)var(--lightningcss-dark,#6366f180);--green:var(--lightningcss-light,#047857)var(--lightningcss-dark,#34d399);--green-soft:var(--lightningcss-light,#0596691a)var(--lightningcss-dark,#34d39921);--red:var(--lightningcss-light,#b91c1c)var(--lightningcss-dark,#f87171);--red-soft:var(--lightningcss-light,#b91c1c17)var(--lightningcss-dark,#f8717121);--amber:var(--lightningcss-light,#b45309)var(--lightningcss-dark,#fbbf24);--amber-soft:var(--lightningcss-light,#b453091a)var(--lightningcss-dark,#fbbf2421);--radius:8px;--radius-sm:5px;--radius-xs:4px;--font:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, "Helvetica Neue", sans-serif;--mono:ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;--shadow:0 1px 2px var(--lightningcss-light,#1018280f)var(--lightningcss-dark,#00000080), 0 10px 28px var(--lightningcss-light,#10182812)var(--lightningcss-dark,#0000004d);--shadow-sm:0 1px 2px var(--lightningcss-light,#1018280f)var(--lightningcss-dark,#0006)}@media (prefers-color-scheme:dark){:root{--lightningcss-light: ;--lightningcss-dark:initial}}:root[data-theme=light]{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light}:root[data-theme=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}*{box-sizing:border-box}html,body,#root{height:100%}body{background:var(--bg);color:var(--text);font-family:var(--font);-webkit-font-smoothing:antialiased;text-rendering:optimizelegibility;margin:0;font-size:14px;line-height:1.5}a{color:var(--accent-hover);text-decoration:none}a:hover{text-decoration:underline}code,.mono{font-family:var(--mono);font-size:.92em}h1,h2,h3,h4{letter-spacing:-.01em;margin:0;font-weight:650}::selection{background:var(--accent-soft)}input[type=checkbox],input[type=radio]{accent-color:var(--accent)}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-thumb{background:var(--border);border:2px solid var(--bg);border-radius:99px}::-webkit-scrollbar-thumb:hover{background:var(--faint)}:focus-visible{outline:2px solid var(--accent-ring);outline-offset:2px;border-radius:4px}.app{grid-template-columns:232px 1fr;min-height:100dvh;display:grid}.sidebar{border-right:1px solid var(--border);background:var(--rail);flex-direction:column;align-self:start;gap:4px;height:100dvh;padding:18px 14px;display:flex;position:sticky;top:0}.brand{align-items:center;gap:10px;padding:6px 8px 14px;display:flex}.brand-logo{background:var(--text);flex-shrink:0;width:26px;height:26px;-webkit-mask:url(/logo.png) 50%/contain no-repeat;mask:url(/logo.png) 50%/contain no-repeat}.brand .name{letter-spacing:-.02em;font-size:15px;font-weight:700;line-height:26px}.brand .ver{font-family:var(--mono);color:var(--muted);background:var(--raised);border:1px solid var(--border);border-radius:99px;align-self:center;padding:2px 6px;font-size:10px;line-height:1}.nav-item{border-radius:var(--radius-sm);text-align:left;cursor:pointer;width:100%;color:var(--muted);font:inherit;background:0 0;border:none;align-items:center;gap:10px;padding:8px 10px;font-size:13.5px;font-weight:500;transition:background .12s,color .12s;display:flex}.nav-item:hover{background:var(--raised);color:var(--text)}.nav-item.active{background:var(--accent-soft);color:var(--text)}.nav-item svg{width:17px;height:17px;color:var(--faint);flex-shrink:0}.nav-item.active svg{color:var(--accent)}.sidebar-foot{flex-direction:column;gap:2px;margin-top:auto;padding-top:12px;display:flex}.sidebar-link{color:var(--muted);border-radius:var(--radius-sm);align-items:center;gap:9px;padding:8px 10px;font-size:13px;display:flex}.sidebar-link:hover{background:var(--raised);color:var(--text);text-decoration:none}.sidebar-link svg{width:16px;height:16px}.theme-toggle{text-align:left;cursor:pointer;width:100%;color:var(--muted);font:inherit;border-radius:var(--radius-sm);background:0 0;border:none;align-items:center;gap:9px;padding:8px 10px;font-size:13px;transition:background .12s,color .12s;display:flex}.theme-toggle:hover{background:var(--raised);color:var(--text)}.theme-toggle svg{flex-shrink:0;width:16px;height:16px}.theme-toggle .mode{text-transform:capitalize}.stop-toggle{color:var(--red)}.stop-toggle:hover{background:var(--red-soft);color:var(--red)}.stop-toggle:disabled{opacity:.5;cursor:default}.main{min-width:0}.main-inner{max-width:980px;margin:0 auto;padding:32px 36px 64px}.page-head{justify-content:space-between;align-items:center;gap:16px;margin-bottom:6px;display:flex}.page-head h2{font-size:19px}.page-sub{color:var(--muted);max-width:70ch;margin:4px 0 22px;font-size:13.5px}.page-sub b{color:var(--text);font-weight:600}.btn{border-radius:var(--radius-sm);font:inherit;cursor:pointer;white-space:nowrap;border:1px solid #0000;justify-content:center;align-items:center;gap:7px;padding:7px 14px;font-size:13px;font-weight:550;transition:background .12s,border-color .12s,opacity .12s;display:inline-flex}.btn svg{width:15px;height:15px}.btn:disabled{opacity:.55;cursor:default}.btn-primary{background:var(--accent);color:var(--accent-ink)}.btn-primary:hover:not(:disabled){background:var(--accent-hover)}.btn-ghost{background:var(--raised);color:var(--text);border-color:var(--border)}.btn-ghost:hover:not(:disabled){background:var(--raised-hover)}.btn-danger{color:var(--red);background:0 0;border-color:#f871714d}.btn-danger:hover:not(:disabled){background:var(--red-soft)}.btn-sm{border-radius:var(--radius-xs);padding:4px 9px;font-size:12px}.btn-icon{padding:5px}.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius)}.panel{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:18px}.panel-accent{background:linear-gradient(180deg, var(--accent-soft), transparent 120%), var(--surface);border-color:#7c5cff47}.stat-row{grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:28px;display:grid}.stat{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px;transition:border-color .12s}.stat:hover{border-color:var(--accent-ring)}.stat .label{color:var(--muted);text-transform:uppercase;letter-spacing:.05em;align-items:center;gap:6px;margin-bottom:9px;font-size:11px;font-weight:600;display:flex}.stat .label svg{width:14px;height:14px}.stat .value{letter-spacing:-.02em;font-size:24px;font-weight:700;line-height:1.1}.stat .value.mono{font-family:var(--mono);font-size:19px}.model-group-head{color:var(--muted);text-transform:uppercase;letter-spacing:.04em;align-items:baseline;gap:8px;margin:0 0 8px;font-size:12px;font-weight:600;display:flex}.model-group-head .count{font-family:var(--mono);text-transform:none;letter-spacing:0;color:var(--faint);font-weight:500}.model-grid{grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:8px;display:grid}.model-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);padding:10px 12px;transition:border-color .12s,background .12s}.model-card:hover{border-color:var(--accent-ring);background:var(--hover)}.model-card .id{font-family:var(--mono);letter-spacing:-.01em;color:var(--text);font-size:13px;font-weight:600}.badge{font-size:11px;font-weight:600;font-family:var(--mono);letter-spacing:.01em;border-radius:99px;align-items:center;gap:5px;padding:2px 8px;display:inline-flex}.badge-accent{background:var(--accent-soft);color:var(--accent-hover)}.badge-green{background:var(--green-soft);color:var(--green)}.badge-amber{background:var(--amber-soft);color:var(--amber)}.badge-muted{background:var(--raised);color:var(--muted);border:1px solid var(--border)}.dot{border-radius:50%;flex-shrink:0;width:7px;height:7px}.dot-green{background:var(--green);box-shadow:0 0 0 3px var(--green-soft)}.dot-red{background:var(--red);box-shadow:0 0 0 3px var(--red-soft)}.tbl{border-collapse:collapse;width:100%;font-size:13px}.tbl thead th{text-align:left;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;border-bottom:1px solid var(--border);padding:9px 12px;font-size:11.5px;font-weight:600}.tbl tbody td{border-bottom:1px solid var(--border-soft);padding:10px 12px}.tbl tbody tr:last-child td{border-bottom:none}.tbl tbody tr:hover td{background:var(--hover)}.tbl .num{text-align:right;font-family:var(--mono)}.tbl-wrap{border:1px solid var(--border);border-radius:var(--radius);overflow-x:auto}.input,textarea.input{border-radius:var(--radius-sm);background:var(--raised);border:1px solid var(--border);width:100%;color:var(--text);font:inherit;padding:8px 11px;font-size:13px;transition:border-color .12s}.input::placeholder{color:var(--faint)}.input:focus{border-color:var(--accent);outline:none}textarea.input{resize:vertical;font-family:var(--mono);line-height:1.55}.field-label{color:var(--muted);margin-bottom:5px;font-size:12px;font-weight:500;display:block}select.input{appearance:none}.select-sm{border-radius:var(--radius-sm);background:var(--raised);border:1px solid var(--border);color:var(--text);font:inherit;cursor:pointer;padding:5px 8px;font-size:13px;transition:border-color .12s}.select-sm:focus{border-color:var(--accent);outline:none}.select-sm:disabled{opacity:.5;cursor:default}.switch{cursor:pointer;background:var(--lightningcss-light,#c5c9d2)var(--lightningcss-dark,#3a3f4b);border:none;border-radius:99px;flex-shrink:0;width:34px;height:19px;padding:0;transition:background .15s;position:relative}.switch.on{background:var(--accent)}.switch:disabled{opacity:.6;cursor:default}.switch .knob{background:#fff;border-radius:50%;width:15px;height:15px;transition:left .15s;position:absolute;top:2px;left:2px;box-shadow:0 1px 2px #1018284d}.switch.on .knob{left:17px}.muted{color:var(--muted)}.faint{color:var(--faint)}.row{align-items:center;gap:10px;display:flex}.spread{justify-content:space-between;align-items:center;gap:12px;display:flex}.stack{flex-direction:column;display:flex}.chip{font-family:var(--mono);background:var(--raised);border:1px solid var(--border);border-radius:var(--radius-xs);color:var(--text);padding:1px 7px;font-size:12px}.empty{text-align:center;border:1px dashed var(--border);border-radius:var(--radius);color:var(--muted);padding:56px 20px}.empty svg{width:30px;height:30px;color:var(--faint);margin-bottom:12px}.empty .title{color:var(--text);margin-bottom:6px;font-weight:600}.notice{border-radius:var(--radius-sm);align-items:center;gap:8px;margin-bottom:14px;padding:9px 12px;font-size:13px;display:flex}.notice svg{flex-shrink:0;width:15px;height:15px}.notice-ok{background:var(--green-soft);color:var(--green)}.notice-err{background:var(--red-soft);color:var(--red)}.h-section{color:var(--text);align-items:center;gap:8px;margin:30px 0 12px;font-size:13px;font-weight:600;display:flex}.h-section .count{color:var(--muted);font-weight:500;font-family:var(--mono);font-size:12px}.spin{border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;width:14px;height:14px;animation:.7s linear infinite spin;display:inline-block}@keyframes spin{to{transform:rotate(360deg)}}@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}.modal-overlay{-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);z-index:50;background:var(--lightningcss-light,#11131c73)var(--lightningcss-dark,#0009);justify-content:center;align-items:flex-start;padding:8vh 16px;display:flex;position:fixed;inset:0}.modal-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);width:100%;max-width:520px;box-shadow:var(--shadow);max-height:84vh;padding:20px;overflow-y:auto}.modal-head{justify-content:space-between;align-items:center;margin-bottom:16px;display:flex}.modal-head h3{font-size:16px}.setup-guide{border:1px solid var(--border);border-radius:var(--radius-sm);margin-bottom:4px;padding:8px 12px;font-size:13px}.setup-guide summary{cursor:pointer;color:var(--accent-hover);font-weight:500}.setup-guide summary:hover{text-decoration:underline}.setup-guide a{color:var(--accent-hover)}.list-row{text-align:left;border-radius:var(--radius-sm);border:1px solid var(--border);background:var(--raised);cursor:pointer;width:100%;color:var(--text);font:inherit;justify-content:space-between;align-items:center;gap:10px;padding:11px 13px;transition:background .12s,border-color .12s;display:flex}.list-row:hover{background:var(--raised-hover);border-color:var(--accent-ring)}.list-row .title{font-size:14px;font-weight:600}.list-row .sub{color:var(--muted);margin-top:2px;font-size:12px}.prov-card{justify-content:space-between;align-items:flex-start;gap:12px;padding:15px 16px;display:flex}.link-btn{color:var(--accent-hover);font:inherit;cursor:pointer;background:0 0;border:none;padding:6px 2px;font-size:13px;text-decoration:underline}@media (width<=760px){.app{grid-template-columns:1fr}.sidebar{z-index:20;border-right:none;border-bottom:1px solid var(--border);background:var(--rail);flex-flow:wrap;align-items:center;gap:0;min-width:0;height:auto;padding:0 10px;position:sticky;top:0}.brand{flex:auto;order:1;width:auto;padding:10px 4px}.sidebar-foot{flex-direction:row;flex:none;order:2;gap:4px;margin:0;padding:0}.sidebar-foot .sidebar-link{display:none}.theme-toggle{justify-content:center;min-width:44px;min-height:44px;padding:8px}.theme-toggle .mode{display:none}.sidebar nav{overscroll-behavior-x:contain;border-top:1px solid var(--border-soft);scrollbar-width:none;flex-direction:row;flex:100%;order:3;gap:2px;min-width:0;margin:0;padding:4px 0 8px;display:flex;overflow-x:auto}.sidebar nav::-webkit-scrollbar{display:none}.nav-item{white-space:nowrap;width:auto;min-height:44px;padding:9px 14px;font-size:14px}.main-inner{padding:22px 18px 48px}.stat-row{grid-template-columns:repeat(2,minmax(0,1fr))}.tbl{min-width:460px}}
|