@bitkyc08/opencodex 2.7.17 → 2.7.19
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-D8ODGlXj.js +40 -0
- package/gui/dist/assets/index-DbIT5GLo.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +4 -2
- package/src/adapters/anthropic-image-guard.ts +63 -7
- package/src/adapters/anthropic-image-normalize.ts +383 -0
- package/src/adapters/anthropic.ts +7 -2
- package/src/adapters/base.ts +6 -0
- package/src/adapters/cursor/exec-policy.ts +9 -1
- package/src/adapters/cursor/live-transport.ts +19 -11
- package/src/adapters/cursor/protobuf-request.ts +7 -6
- package/src/adapters/kiro-images.ts +94 -0
- package/src/adapters/kiro.ts +6 -2
- package/src/adapters/mimo-free.ts +228 -0
- package/src/adapters/openai-chat.ts +153 -1
- package/src/adapters/openai-responses.ts +128 -2
- package/src/cli/claude.ts +3 -0
- package/src/codex/catalog.ts +25 -1
- package/src/oauth/callback-server.ts +17 -7
- package/src/oauth/index.ts +93 -0
- package/src/oauth/types.ts +1 -1
- package/src/providers/derive.ts +4 -0
- package/src/providers/registry.ts +64 -3
- package/src/server/adapter-resolve.ts +3 -0
- package/src/server/auth-cors.ts +4 -0
- package/src/server/claude-messages.ts +11 -0
- package/src/server/image-retry.ts +42 -0
- package/src/server/management-api.ts +57 -1
- package/src/server/relay.ts +6 -2
- package/src/server/request-log.ts +4 -1
- package/src/server/responses.ts +74 -22
- package/src/server/system-env.ts +7 -3
- package/src/types.ts +22 -4
- package/src/web-search/index.ts +8 -5
- package/gui/dist/assets/index-Cq8maiJf.css +0 -1
- package/gui/dist/assets/index-m4o3xsSn.js +0 -40
|
@@ -2,6 +2,7 @@ import type { ProviderAdapter } from "./base";
|
|
|
2
2
|
import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall, OcxUsage } from "../types";
|
|
3
3
|
import { isAllowedToolChoice, modelInList, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice } from "../types";
|
|
4
4
|
import { mapReasoningEffort } from "../reasoning-effort";
|
|
5
|
+
import { redactSecretString } from "../lib/redact";
|
|
5
6
|
import { contentPartsToText } from "./image";
|
|
6
7
|
import { neutralizeIdentity } from "./identity";
|
|
7
8
|
import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge";
|
|
@@ -13,6 +14,52 @@ export function stripBracketedModelSuffix(modelId: string): string {
|
|
|
13
14
|
return modelId.replace(/\[[^\]]*\]\s*$/, "");
|
|
14
15
|
}
|
|
15
16
|
|
|
17
|
+
// 260715 (issue #126): surface upstream error detail through the web-search sidecar loop.
|
|
18
|
+
// loop.ts only appends a suffix to "Provider error N" when the adapter exposes
|
|
19
|
+
// formatErrorBody; without it, strict OpenAI-compatible backends (NVIDIA NIM pydantic
|
|
20
|
+
// validation, "This model only supports single tool-calls at once!", etc.) were reduced
|
|
21
|
+
// to a bare status code. JSON-only extraction: recognized string fields are returned,
|
|
22
|
+
// HTML/non-JSON bodies yield "" so raw markup is never echoed to the client.
|
|
23
|
+
export function formatOpenAIChatErrorBody(status: number, _headers: Headers, payloadText: string): string {
|
|
24
|
+
let parsed: unknown;
|
|
25
|
+
try {
|
|
26
|
+
parsed = JSON.parse(payloadText);
|
|
27
|
+
} catch {
|
|
28
|
+
return "";
|
|
29
|
+
}
|
|
30
|
+
const detail = extractErrorDetail(parsed);
|
|
31
|
+
if (!detail) return "";
|
|
32
|
+
return redactSecretString(detail).slice(0, 400);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function extractErrorDetail(parsed: unknown): string | undefined {
|
|
36
|
+
if (typeof parsed === "string") return parsed.trim() || undefined;
|
|
37
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
38
|
+
const obj = parsed as Record<string, unknown>;
|
|
39
|
+
// OpenAI shape: { error: { message } } or { error: "..." }
|
|
40
|
+
const err = obj.error;
|
|
41
|
+
if (typeof err === "string" && err.trim()) return err.trim();
|
|
42
|
+
if (err !== null && typeof err === "object" && !Array.isArray(err)) {
|
|
43
|
+
const msg = (err as Record<string, unknown>).message;
|
|
44
|
+
if (typeof msg === "string" && msg.trim()) return msg.trim();
|
|
45
|
+
}
|
|
46
|
+
// FastAPI/pydantic shape (NVIDIA NIM): { detail: "..." } or { detail: [{ msg, loc }, ...] }
|
|
47
|
+
const det = obj.detail;
|
|
48
|
+
if (typeof det === "string" && det.trim()) return det.trim();
|
|
49
|
+
if (Array.isArray(det)) {
|
|
50
|
+
const msgs = det
|
|
51
|
+
.map(item => (item !== null && typeof item === "object" && typeof (item as Record<string, unknown>).msg === "string"
|
|
52
|
+
? ((item as Record<string, unknown>).msg as string).trim()
|
|
53
|
+
: ""))
|
|
54
|
+
.filter(m => m.length > 0);
|
|
55
|
+
if (msgs.length > 0) return msgs.join("; ");
|
|
56
|
+
}
|
|
57
|
+
// Generic fallbacks: { message } / RFC7807 { title }
|
|
58
|
+
if (typeof obj.message === "string" && obj.message.trim()) return obj.message.trim();
|
|
59
|
+
if (typeof obj.title === "string" && obj.title.trim()) return obj.title.trim();
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
16
63
|
function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] {
|
|
17
64
|
const out: unknown[] = [];
|
|
18
65
|
const { context, options } = parsed;
|
|
@@ -125,6 +172,88 @@ function safeToolName(name: string | undefined): string {
|
|
|
125
172
|
return sanitized;
|
|
126
173
|
}
|
|
127
174
|
|
|
175
|
+
const ZEN_SCHEMA_MAP_KEYS = new Set(["properties", "$defs", "definitions"]);
|
|
176
|
+
const ZEN_DROPPED_SCHEMA_KEYS = new Set(["encrypted"]);
|
|
177
|
+
|
|
178
|
+
function sanitizeZenSchemaMap(value: unknown): unknown {
|
|
179
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return sanitizeZenToolParameters(value);
|
|
180
|
+
const out: Record<string, unknown> = {};
|
|
181
|
+
for (const [name, child] of Object.entries(value as Record<string, unknown>)) {
|
|
182
|
+
out[name] = sanitizeZenToolParameters(child);
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function sanitizeZenToolParameters(value: unknown): unknown {
|
|
188
|
+
if (Array.isArray(value)) return value.map(sanitizeZenToolParameters);
|
|
189
|
+
if (!value || typeof value !== "object") return value;
|
|
190
|
+
const input = value as Record<string, unknown>;
|
|
191
|
+
const out: Record<string, unknown> = {};
|
|
192
|
+
for (const [key, child] of Object.entries(input)) {
|
|
193
|
+
if (ZEN_DROPPED_SCHEMA_KEYS.has(key)) continue;
|
|
194
|
+
if (key === "required" && Array.isArray(child) && child.length === 0) continue;
|
|
195
|
+
if (key === "type" && Array.isArray(child)) {
|
|
196
|
+
const nonNull = child.filter(entry => entry !== "null");
|
|
197
|
+
if (child.includes("null")) out.nullable = true;
|
|
198
|
+
if (nonNull.length > 0) out.type = nonNull[0];
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
out[key] = ZEN_SCHEMA_MAP_KEYS.has(key) ? sanitizeZenSchemaMap(child) : sanitizeZenToolParameters(child);
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function ensureZenRootObjectSchema(schema: unknown): Record<string, unknown> {
|
|
207
|
+
const obj = schema && typeof schema === "object" && !Array.isArray(schema)
|
|
208
|
+
? schema as Record<string, unknown>
|
|
209
|
+
: {};
|
|
210
|
+
const compositionKeys = ["oneOf", "anyOf", "allOf"] as const;
|
|
211
|
+
const hasComposition = compositionKeys.some(key => Array.isArray(obj[key]));
|
|
212
|
+
const rootType = obj.type;
|
|
213
|
+
const rootObjectType = rootType === "object" || (Array.isArray(rootType) && rootType.includes("object"));
|
|
214
|
+
if (!hasComposition) {
|
|
215
|
+
const base = sanitizeZenToolParameters(obj) as Record<string, unknown>;
|
|
216
|
+
return rootObjectType && base.type === "object" ? base : { ...base, type: "object" };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const props: Record<string, unknown> = {};
|
|
220
|
+
const required = new Set<string>();
|
|
221
|
+
if (obj.properties && typeof obj.properties === "object") {
|
|
222
|
+
Object.assign(props, sanitizeZenSchemaMap(obj.properties) as Record<string, unknown>);
|
|
223
|
+
}
|
|
224
|
+
if (Array.isArray(obj.required)) {
|
|
225
|
+
for (const entry of obj.required) if (typeof entry === "string") required.add(entry);
|
|
226
|
+
}
|
|
227
|
+
for (const key of compositionKeys) {
|
|
228
|
+
const variants = obj[key];
|
|
229
|
+
if (!Array.isArray(variants)) continue;
|
|
230
|
+
const mergeRequired = key === "allOf";
|
|
231
|
+
for (const variant of variants) {
|
|
232
|
+
if (!variant || typeof variant !== "object" || Array.isArray(variant)) continue;
|
|
233
|
+
const rec = variant as Record<string, unknown>;
|
|
234
|
+
if (rec.properties && typeof rec.properties === "object") {
|
|
235
|
+
Object.assign(props, sanitizeZenSchemaMap(rec.properties) as Record<string, unknown>);
|
|
236
|
+
}
|
|
237
|
+
if (mergeRequired && Array.isArray(rec.required)) {
|
|
238
|
+
for (const entry of rec.required) if (typeof entry === "string") required.add(entry);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const merged = sanitizeZenToolParameters(obj) as Record<string, unknown>;
|
|
244
|
+
delete merged.oneOf;
|
|
245
|
+
delete merged.anyOf;
|
|
246
|
+
delete merged.allOf;
|
|
247
|
+
merged.type = "object";
|
|
248
|
+
if (Object.keys(props).length > 0) merged.properties = props;
|
|
249
|
+
if (required.size > 0) merged.required = [...required];
|
|
250
|
+
return merged;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function shouldSanitizeZenToolParameters(provider: OcxProviderConfig): boolean {
|
|
254
|
+
return provider.baseUrl.replace(/\/+$/, "") === "https://opencode.ai/zen/v1";
|
|
255
|
+
}
|
|
256
|
+
|
|
128
257
|
const XAI_SCHEMA_BASE_URLS = new Set(["api.x.ai", "cli-chat-proxy.grok.com"]);
|
|
129
258
|
|
|
130
259
|
function isXaiSchemaTarget(provider: OcxProviderConfig): boolean {
|
|
@@ -193,6 +322,23 @@ function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig
|
|
|
193
322
|
return formatted.length > 0 ? formatted : undefined;
|
|
194
323
|
}
|
|
195
324
|
|
|
325
|
+
function toolsToChatFormatForProvider(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] | undefined {
|
|
326
|
+
const base = toolsToChatFormat(parsed, provider);
|
|
327
|
+
if (!base || !shouldSanitizeZenToolParameters(provider)) return base;
|
|
328
|
+
return base.map(tool => {
|
|
329
|
+
if (!tool || typeof tool !== "object") return tool;
|
|
330
|
+
const functionDef = (tool as { function?: Record<string, unknown> }).function;
|
|
331
|
+
if (!functionDef || typeof functionDef !== "object") return tool;
|
|
332
|
+
return {
|
|
333
|
+
...tool,
|
|
334
|
+
function: {
|
|
335
|
+
...functionDef,
|
|
336
|
+
parameters: ensureZenRootObjectSchema(functionDef.parameters ?? {}),
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
196
342
|
function toolChoiceToChatFormat(tc: OcxParsedRequest["options"]["toolChoice"], tools: OcxParsedRequest["context"]["tools"]): unknown {
|
|
197
343
|
if (!tc) return undefined;
|
|
198
344
|
if (isAllowedToolChoice(tc)) return tc.mode === "required" ? "required" : "auto";
|
|
@@ -231,6 +377,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
231
377
|
return {
|
|
232
378
|
name: "openai-chat",
|
|
233
379
|
|
|
380
|
+
formatErrorBody: formatOpenAIChatErrorBody,
|
|
381
|
+
|
|
234
382
|
buildRequest(parsed: OcxParsedRequest) {
|
|
235
383
|
const hasCredential = typeof provider.apiKey === "string" && provider.apiKey.trim().length > 0;
|
|
236
384
|
if ((provider.authMode === "key" || provider.authMode === "oauth") && !provider.keyOptional && !hasCredential) {
|
|
@@ -238,7 +386,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
238
386
|
}
|
|
239
387
|
|
|
240
388
|
const messages = messagesToChatFormat(parsed, provider);
|
|
241
|
-
const tools =
|
|
389
|
+
const tools = toolsToChatFormatForProvider(parsed, provider);
|
|
242
390
|
const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools);
|
|
243
391
|
|
|
244
392
|
const body: Record<string, unknown> = {
|
|
@@ -297,6 +445,10 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
297
445
|
|
|
298
446
|
const url = `${provider.baseUrl}/chat/completions`;
|
|
299
447
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
448
|
+
// Precedence preserved from pre-#128 behavior: apiKey Authorization first, then
|
|
449
|
+
// provider.headers may override (user/registry-configured headers win). Registry
|
|
450
|
+
// staticHeaders (e.g. opencode-free x-opencode-client) flow in via derive.ts and
|
|
451
|
+
// never carry Authorization, so keyless providers are unaffected.
|
|
300
452
|
if (hasCredential) headers["Authorization"] = `Bearer ${provider.apiKey}`;
|
|
301
453
|
if (provider.headers) Object.assign(headers, provider.headers);
|
|
302
454
|
|
|
@@ -136,9 +136,135 @@ function scrubOcxCompactionItems(body: unknown): unknown {
|
|
|
136
136
|
* Extend this when another native slug rejects a hosted tool (e.g. `code_interpreter`).
|
|
137
137
|
*/
|
|
138
138
|
const UNSUPPORTED_HOSTED_TOOLS: ReadonlyArray<{ match: (model: string) => boolean; tools: ReadonlySet<string> }> = [
|
|
139
|
-
{ match: model => model.includes("codex-spark"), tools: new Set(["image_generation"]) },
|
|
139
|
+
{ match: model => model.includes("codex-spark"), tools: new Set(["image_generation", "tool_search"]) },
|
|
140
140
|
];
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Strip unsupported `reasoning` sub-parameters for native slugs that reject them (e.g. Spark).
|
|
144
|
+
* codex-rs injects `reasoning.context` and `reasoning.summary` based on catalog flags; Spark's
|
|
145
|
+
* backend rejects both. The catalog fix prevents `use_responses_lite` from being set, but this
|
|
146
|
+
* is a defense-in-depth guard so stale on-disk catalogs don't break until the user runs `ocx sync`.
|
|
147
|
+
*/
|
|
148
|
+
function stripUnsupportedReasoningParams(body: unknown): unknown {
|
|
149
|
+
if (!isPlainObject(body)) return body;
|
|
150
|
+
const model = typeof body.model === "string" ? body.model : "";
|
|
151
|
+
if (!model.includes("codex-spark")) return body;
|
|
152
|
+
if (!isPlainObject(body.reasoning)) return body;
|
|
153
|
+
const reasoning = body.reasoning as Record<string, unknown>;
|
|
154
|
+
// Spark supports reasoning.effort but rejects context, summary, and generate_summary.
|
|
155
|
+
const { context: _ctx, summary: _sum, generate_summary: _gs, ...rest } = reasoning;
|
|
156
|
+
if (_ctx === undefined && _sum === undefined && _gs === undefined) return body;
|
|
157
|
+
return { ...body, reasoning: Object.keys(rest).length > 0 ? rest : undefined };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Comprehensive Spark compatibility layer. codex-rs emits five tool types (function,
|
|
162
|
+
* namespace, tool_search, web_search, custom) plus extensions (defer_loading,
|
|
163
|
+
* parallel_tool_calls, tool_search_call/output items). Spark's serving path only
|
|
164
|
+
* supports flat function tools and hosted web_search. This function:
|
|
165
|
+
* - Flattens namespace tools → promotes inner functions to top level
|
|
166
|
+
* - Drops unsupported tool types (tool_search, custom)
|
|
167
|
+
* - Strips defer_loading from function tools
|
|
168
|
+
* - Strips namespace from input items
|
|
169
|
+
* - Drops tool_search_call/tool_search_output input items
|
|
170
|
+
* - Sets parallel_tool_calls to false
|
|
171
|
+
*/
|
|
172
|
+
function stripSparkCompatibility(body: unknown): unknown {
|
|
173
|
+
if (!isPlainObject(body)) return body;
|
|
174
|
+
const model = typeof body.model === "string" ? body.model : "";
|
|
175
|
+
if (!model.includes("codex-spark")) return body;
|
|
176
|
+
|
|
177
|
+
let changed = false;
|
|
178
|
+
|
|
179
|
+
const SPARK_SAFE_TOOL_TYPES = new Set(["function", "web_search", "web_search_preview"]);
|
|
180
|
+
|
|
181
|
+
let tools = body.tools;
|
|
182
|
+
if (Array.isArray(tools)) {
|
|
183
|
+
const flattened: unknown[] = [];
|
|
184
|
+
for (const t of tools) {
|
|
185
|
+
if (isPlainObject(t) && t.type === "namespace") {
|
|
186
|
+
changed = true;
|
|
187
|
+
if (Array.isArray(t.tools)) {
|
|
188
|
+
for (const inner of t.tools) flattened.push(inner);
|
|
189
|
+
}
|
|
190
|
+
} else if (isPlainObject(t) && typeof t.type === "string" && !SPARK_SAFE_TOOL_TYPES.has(t.type)) {
|
|
191
|
+
changed = true;
|
|
192
|
+
} else {
|
|
193
|
+
flattened.push(t);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// Strip defer_loading from promoted/remaining function tools.
|
|
197
|
+
tools = flattened.map(t => {
|
|
198
|
+
if (isPlainObject(t) && t.type === "function" && "defer_loading" in t) {
|
|
199
|
+
const { defer_loading: _, ...rest } = t;
|
|
200
|
+
changed = true;
|
|
201
|
+
return rest;
|
|
202
|
+
}
|
|
203
|
+
return t;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Clean input items: strip namespace, drop tool_search_call/tool_search_output.
|
|
208
|
+
const SPARK_UNSUPPORTED_INPUT_TYPES = new Set([
|
|
209
|
+
"tool_search_call", "tool_search_output",
|
|
210
|
+
"custom_tool_call", "custom_tool_call_output",
|
|
211
|
+
]);
|
|
212
|
+
let input = body.input;
|
|
213
|
+
if (Array.isArray(input)) {
|
|
214
|
+
const cleaned: unknown[] = [];
|
|
215
|
+
for (const item of input) {
|
|
216
|
+
if (isPlainObject(item) && typeof item.type === "string" && SPARK_UNSUPPORTED_INPUT_TYPES.has(item.type)) {
|
|
217
|
+
changed = true;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
// Process additional_tools items: filter their inner tools array the same way.
|
|
221
|
+
if (isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools)) {
|
|
222
|
+
const innerTools = item.tools as unknown[];
|
|
223
|
+
const filteredInner: unknown[] = [];
|
|
224
|
+
for (const t of innerTools) {
|
|
225
|
+
if (isPlainObject(t) && t.type === "namespace") {
|
|
226
|
+
changed = true;
|
|
227
|
+
if (Array.isArray(t.tools)) {
|
|
228
|
+
for (const fn of t.tools) filteredInner.push(fn);
|
|
229
|
+
}
|
|
230
|
+
} else if (isPlainObject(t) && typeof t.type === "string" && !SPARK_SAFE_TOOL_TYPES.has(t.type)) {
|
|
231
|
+
changed = true; // drop custom, tool_search, etc.
|
|
232
|
+
} else {
|
|
233
|
+
filteredInner.push(t);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// Strip defer_loading from remaining function tools.
|
|
237
|
+
const cleanedInner = filteredInner.map(t => {
|
|
238
|
+
if (isPlainObject(t) && t.type === "function" && "defer_loading" in t) {
|
|
239
|
+
const { defer_loading: _, ...rest } = t;
|
|
240
|
+
changed = true;
|
|
241
|
+
return rest;
|
|
242
|
+
}
|
|
243
|
+
return t;
|
|
244
|
+
});
|
|
245
|
+
cleaned.push({ ...item, tools: cleanedInner });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (isPlainObject(item) && "namespace" in item) {
|
|
249
|
+
const { namespace: _, ...rest } = item;
|
|
250
|
+
changed = true;
|
|
251
|
+
cleaned.push(rest);
|
|
252
|
+
} else {
|
|
253
|
+
cleaned.push(item);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (changed) input = cleaned;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Force parallel_tool_calls off for Spark.
|
|
260
|
+
const extraOverrides: Record<string, unknown> = {};
|
|
261
|
+
if (body.parallel_tool_calls === true) { extraOverrides.parallel_tool_calls = false; changed = true; }
|
|
262
|
+
|
|
263
|
+
return changed
|
|
264
|
+
? { ...body, ...(tools !== body.tools ? { tools } : {}), ...(input !== body.input ? { input } : {}), ...extraOverrides }
|
|
265
|
+
: body;
|
|
266
|
+
}
|
|
267
|
+
|
|
142
268
|
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
143
269
|
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
144
270
|
}
|
|
@@ -331,7 +457,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
|
|
|
331
457
|
url,
|
|
332
458
|
method: "POST",
|
|
333
459
|
headers,
|
|
334
|
-
body: JSON.stringify(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody)))))),
|
|
460
|
+
body: JSON.stringify(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody)))))))),
|
|
335
461
|
};
|
|
336
462
|
},
|
|
337
463
|
|
package/src/cli/claude.ts
CHANGED
|
@@ -54,6 +54,9 @@ export function buildClaudeEnv(config: OcxConfig, port: number, base: ClaudeLaun
|
|
|
54
54
|
if ((config.apiKeys?.length ?? 0) > 0) {
|
|
55
55
|
setDefault("ANTHROPIC_AUTH_TOKEN", config.apiKeys![0].key);
|
|
56
56
|
}
|
|
57
|
+
if (!env.ANTHROPIC_AUTH_TOKEN && config.claudeCode?.authMode === "proxy") {
|
|
58
|
+
env.ANTHROPIC_AUTH_TOKEN = "opencodex-proxy";
|
|
59
|
+
}
|
|
57
60
|
// NOTE: do NOT set _CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL here. While it enables
|
|
58
61
|
// Design/Remote Control, it DISABLES gateway model discovery (Claude Code's eligibility
|
|
59
62
|
// check returns false when isFirstPartyBaseUrl() is true). Model routing through the
|
package/src/codex/catalog.ts
CHANGED
|
@@ -851,6 +851,17 @@ function deriveEntry(template: RawEntry | null, slug: string, desc: string, prio
|
|
|
851
851
|
applyNativeOpenAiContextOverride(e);
|
|
852
852
|
if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(e);
|
|
853
853
|
else ensureUltraReasoningLevel(e);
|
|
854
|
+
// Non-5.6 natives (5.5, 5.4, 5.4-mini, spark) do not support responses-lite;
|
|
855
|
+
// the template may carry the flag from a 5.6 entry — strip it so codex-rs does
|
|
856
|
+
// not inject reasoning.context: "all_turns" for models that reject it.
|
|
857
|
+
if (!isGpt56NativeSlug(slug)) {
|
|
858
|
+
// Spark NEEDS use_responses_lite: true — it controls the tool delivery format
|
|
859
|
+
// (AdditionalTools in input vs top-level tools). The reasoning params that
|
|
860
|
+
// use_responses_lite triggers (context: "all_turns", summary) are stripped
|
|
861
|
+
// separately in the passthrough adapter (stripUnsupportedReasoningParams).
|
|
862
|
+
if (!slug.includes("codex-spark")) delete e.use_responses_lite;
|
|
863
|
+
delete e.supports_websockets;
|
|
864
|
+
}
|
|
854
865
|
}
|
|
855
866
|
return ensureStrictCatalogFields(normalizeServiceTiers(e));
|
|
856
867
|
}
|
|
@@ -1179,7 +1190,8 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
|
|
|
1179
1190
|
provider: name,
|
|
1180
1191
|
owned_by: m.owned_by,
|
|
1181
1192
|
...catalogHintsFromModelsApiItem(name, m),
|
|
1182
|
-
}, contextCap))
|
|
1193
|
+
}, contextCap))
|
|
1194
|
+
.filter(m => shouldExposeProviderModel(name, m.id));
|
|
1183
1195
|
const liveIds = new Set(live.map(m => m.id));
|
|
1184
1196
|
// Dated-release aliases (Anthropic pattern): older models may appear in the live catalog
|
|
1185
1197
|
// ONLY under their dated id (claude-haiku-4-5-20251001) while the config names the
|
|
@@ -1193,6 +1205,8 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
|
|
|
1193
1205
|
if (dated) {
|
|
1194
1206
|
// Reapply config hints so alias-keyed overrides (modelContextWindows etc.) win.
|
|
1195
1207
|
live.push(applyProviderConfigHints(name, prov, { ...dated, id: m.id }, contextCap));
|
|
1208
|
+
} else if (shouldRetainConfiguredProviderModel(name, m.id)) {
|
|
1209
|
+
live.push(m);
|
|
1196
1210
|
} else {
|
|
1197
1211
|
droppedConfiguredIds.push(m.id);
|
|
1198
1212
|
}
|
|
@@ -1213,6 +1227,16 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
|
|
|
1213
1227
|
}
|
|
1214
1228
|
}
|
|
1215
1229
|
|
|
1230
|
+
function shouldExposeProviderModel(providerName: string, modelId: string): boolean {
|
|
1231
|
+
if (providerName === "opencode-free") return modelId === "big-pickle" || modelId.endsWith("-free");
|
|
1232
|
+
return true;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
function shouldRetainConfiguredProviderModel(providerName: string, modelId: string): boolean {
|
|
1236
|
+
if (providerName === "opencode-free") return modelId === "big-pickle" || modelId.endsWith("-free");
|
|
1237
|
+
return false;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1216
1240
|
/**
|
|
1217
1241
|
* Narrow a raw routed-model list to what Codex's catalog / clients should see: drop the
|
|
1218
1242
|
* `disabledModels` blocklist AND, for any provider with a non-empty `selectedModels` allowlist, keep
|
|
@@ -235,12 +235,16 @@ export abstract class OAuthCallbackFlow {
|
|
|
235
235
|
while (true) {
|
|
236
236
|
const result = await Promise.race([
|
|
237
237
|
callbackPromise,
|
|
238
|
-
requestManualInput()
|
|
238
|
+
requestManualInput(expectedState)
|
|
239
239
|
.then((input): CallbackResult | null => {
|
|
240
240
|
const parsed = parseCallbackInput(input);
|
|
241
241
|
if (!parsed.code) return null;
|
|
242
|
-
|
|
243
|
-
|
|
242
|
+
// Kind-aware state enforcement: url/query-shaped input is an authorization
|
|
243
|
+
// RESPONSE and must carry a matching state — missing state is rejected, not
|
|
244
|
+
// downgraded to raw. Only a syntactically raw code (same PKCE session) is
|
|
245
|
+
// exempt, so the CLI/GUI paste fallback still works.
|
|
246
|
+
if (parsed.kind !== "raw" && expectedState && parsed.state !== expectedState) return null;
|
|
247
|
+
return { code: parsed.code, state: parsed.state ?? expectedState };
|
|
244
248
|
})
|
|
245
249
|
.catch((): CallbackResult | null => null),
|
|
246
250
|
]);
|
|
@@ -255,14 +259,19 @@ export abstract class OAuthCallbackFlow {
|
|
|
255
259
|
}
|
|
256
260
|
}
|
|
257
261
|
|
|
258
|
-
/**
|
|
259
|
-
|
|
262
|
+
/**
|
|
263
|
+
* Parse a redirect URL or code string to extract code and state.
|
|
264
|
+
* `kind` records the syntactic shape so callers can enforce state on authorization
|
|
265
|
+
* responses (url/query) while exempting raw in-session codes.
|
|
266
|
+
*/
|
|
267
|
+
export function parseCallbackInput(input: string): { kind: "url" | "query" | "raw"; code?: string; state?: string } {
|
|
260
268
|
const value = input.trim();
|
|
261
|
-
if (!value) return {};
|
|
269
|
+
if (!value) return { kind: "raw" };
|
|
262
270
|
|
|
263
271
|
try {
|
|
264
272
|
const url = new URL(value);
|
|
265
273
|
return {
|
|
274
|
+
kind: "url",
|
|
266
275
|
code: url.searchParams.get("code") ?? undefined,
|
|
267
276
|
state: url.searchParams.get("state") ?? undefined,
|
|
268
277
|
};
|
|
@@ -273,6 +282,7 @@ export function parseCallbackInput(input: string): { code?: string; state?: stri
|
|
|
273
282
|
if (value.includes("code=")) {
|
|
274
283
|
const params = new URLSearchParams(value.replace(/^[?#]/, ""));
|
|
275
284
|
return {
|
|
285
|
+
kind: "query",
|
|
276
286
|
code: params.get("code") ?? undefined,
|
|
277
287
|
state: params.get("state") ?? undefined,
|
|
278
288
|
};
|
|
@@ -280,5 +290,5 @@ export function parseCallbackInput(input: string): { code?: string; state?: stri
|
|
|
280
290
|
|
|
281
291
|
// Assume raw code, possibly with state after #
|
|
282
292
|
const [code, state] = value.split("#", 2);
|
|
283
|
-
return { code, state };
|
|
293
|
+
return { kind: "raw", code, state };
|
|
284
294
|
}
|
package/src/oauth/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { OAuthController, OAuthCredentials } from "./types";
|
|
2
|
+
import { parseCallbackInput } from "./callback-server";
|
|
2
3
|
import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
|
|
3
4
|
import { loadConfig, resolveEnvValue, saveConfig } from "../config";
|
|
4
5
|
import { maskEmail } from "../lib/privacy";
|
|
@@ -362,10 +363,95 @@ export async function runLogin(provider: string, ctrl: OAuthController, opts?: L
|
|
|
362
363
|
* GUI async login: start the flow, return the auth URL EARLY (the flow keeps running in the
|
|
363
364
|
* background until the callback server captures the redirect), with a concurrency guard and an
|
|
364
365
|
* error surfaced via getLoginStatus().
|
|
366
|
+
*
|
|
367
|
+
* Manual fallback: when the browser cannot reach the loopback callback (remote GUI, SSH, blocked
|
|
368
|
+
* localhost), the GUI can POST the final redirect URL or authorization code via
|
|
369
|
+
* submitManualLoginCode(), which feeds OAuthController.onManualCodeInput.
|
|
365
370
|
*/
|
|
366
371
|
const loginState = new Map<string, { error?: string; done: boolean }>();
|
|
367
372
|
const loginAbort = new Map<string, AbortController>();
|
|
368
373
|
|
|
374
|
+
/** Pending paste for a login in progress: either a waiter or a stashed early submission. */
|
|
375
|
+
interface ManualCodeSlot {
|
|
376
|
+
pendingInput?: string;
|
|
377
|
+
resolve?: (value: string) => void;
|
|
378
|
+
/** Registered by the callback flow so submits can validate state synchronously. */
|
|
379
|
+
expectedState?: string;
|
|
380
|
+
}
|
|
381
|
+
const loginManual = new Map<string, ManualCodeSlot>();
|
|
382
|
+
|
|
383
|
+
function clearManualCodeSlot(provider: string): void {
|
|
384
|
+
loginManual.delete(provider);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function ensureManualCodeSlot(provider: string): ManualCodeSlot {
|
|
388
|
+
let slot = loginManual.get(provider);
|
|
389
|
+
if (!slot) {
|
|
390
|
+
slot = {};
|
|
391
|
+
loginManual.set(provider, slot);
|
|
392
|
+
}
|
|
393
|
+
return slot;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Wait for a GUI/CLI paste of the OAuth redirect URL or code (or return a stashed early submit). */
|
|
397
|
+
function waitForManualLoginCode(provider: string, signal: AbortSignal, expectedState?: string): Promise<string> {
|
|
398
|
+
if (signal.aborted) {
|
|
399
|
+
return Promise.reject(new Error(`OAuth callback cancelled: ${signal.reason}`));
|
|
400
|
+
}
|
|
401
|
+
const slot = ensureManualCodeSlot(provider);
|
|
402
|
+
if (expectedState !== undefined) slot.expectedState = expectedState;
|
|
403
|
+
if (slot.pendingInput !== undefined) {
|
|
404
|
+
const value = slot.pendingInput;
|
|
405
|
+
slot.pendingInput = undefined;
|
|
406
|
+
return Promise.resolve(value);
|
|
407
|
+
}
|
|
408
|
+
return new Promise<string>((resolve, reject) => {
|
|
409
|
+
const onAbort = () => {
|
|
410
|
+
if (slot.resolve === resolve) slot.resolve = undefined;
|
|
411
|
+
reject(new Error(`OAuth callback cancelled: ${signal.reason}`));
|
|
412
|
+
};
|
|
413
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
414
|
+
slot.resolve = (value: string) => {
|
|
415
|
+
signal.removeEventListener("abort", onAbort);
|
|
416
|
+
if (slot.resolve === resolve) slot.resolve = undefined;
|
|
417
|
+
resolve(value);
|
|
418
|
+
};
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Feed a pasted redirect URL or authorization code into an in-progress GUI login.
|
|
424
|
+
* Returns ok:false when no login is waiting (or input is empty). Invalid pastes are accepted
|
|
425
|
+
* here and re-prompted by the OAuth callback loop if they cannot be parsed / fail state checks.
|
|
426
|
+
*/
|
|
427
|
+
export function submitManualLoginCode(provider: string, input: string): { ok: true } | { ok: false; error: string } {
|
|
428
|
+
const trimmed = input.trim();
|
|
429
|
+
if (!trimmed) return { ok: false, error: "empty code" };
|
|
430
|
+
const st = loginState.get(provider);
|
|
431
|
+
if (!st || st.done) return { ok: false, error: "no login in progress" };
|
|
432
|
+
const slot = ensureManualCodeSlot(provider);
|
|
433
|
+
// Synchronous validation (validated request/ack): reject un-parseable input and
|
|
434
|
+
// authorization responses (url/query kind) whose state is missing or mismatched
|
|
435
|
+
// once the flow has registered its expected state. Raw codes stay in-session-PKCE
|
|
436
|
+
// protected. Early posts (flow not yet waiting, no expectedState) are stashed and
|
|
437
|
+
// re-validated by the callback loop.
|
|
438
|
+
const parsed = parseCallbackInput(trimmed);
|
|
439
|
+
if (!parsed.code) return { ok: false, error: "no authorization code found in input" };
|
|
440
|
+
if (parsed.kind !== "raw" && slot.expectedState !== undefined) {
|
|
441
|
+
if (parsed.state === undefined) return { ok: false, error: "redirect URL is missing the state parameter" };
|
|
442
|
+
if (parsed.state !== slot.expectedState) return { ok: false, error: "state mismatch — paste the redirect URL from THIS login attempt" };
|
|
443
|
+
}
|
|
444
|
+
if (slot.resolve) {
|
|
445
|
+
const resolve = slot.resolve;
|
|
446
|
+
slot.resolve = undefined;
|
|
447
|
+
resolve(trimmed);
|
|
448
|
+
} else {
|
|
449
|
+
// Race: GUI may POST before the flow reaches onManualCodeInput — stash for the waiter.
|
|
450
|
+
slot.pendingInput = trimmed;
|
|
451
|
+
}
|
|
452
|
+
return { ok: true };
|
|
453
|
+
}
|
|
454
|
+
|
|
369
455
|
export interface OAuthAccountSummary { id: string; email?: string; active: boolean; needsReauth?: boolean; expiresAt?: number }
|
|
370
456
|
|
|
371
457
|
export function getLoginStatus(provider: string): { loggedIn: boolean; email?: string; source?: OAuthCredentials["source"]; error?: string; done: boolean; activeAccountId?: string; accounts?: OAuthAccountSummary[] } {
|
|
@@ -400,6 +486,7 @@ export function oauthLoginSummary(): Array<{ provider: string; loggedIn: boolean
|
|
|
400
486
|
export function clearLoginState(provider: string): void {
|
|
401
487
|
loginAbort.get(provider)?.abort("cleared");
|
|
402
488
|
loginAbort.delete(provider);
|
|
489
|
+
clearManualCodeSlot(provider);
|
|
403
490
|
loginState.delete(provider);
|
|
404
491
|
}
|
|
405
492
|
|
|
@@ -409,6 +496,7 @@ export function cancelLoginFlow(provider: string): boolean {
|
|
|
409
496
|
if (!ctrl && (!existing || existing.done)) return false;
|
|
410
497
|
ctrl?.abort("cancelled");
|
|
411
498
|
loginAbort.delete(provider);
|
|
499
|
+
clearManualCodeSlot(provider);
|
|
412
500
|
loginState.set(provider, { done: true, error: "Login cancelled" });
|
|
413
501
|
return true;
|
|
414
502
|
}
|
|
@@ -420,6 +508,7 @@ export async function startLoginFlow(provider: string, opts?: LoginOpts): Promis
|
|
|
420
508
|
if (existing && !existing.done) {
|
|
421
509
|
throw new Error(`A login for ${provider} is already in progress`);
|
|
422
510
|
}
|
|
511
|
+
clearManualCodeSlot(provider);
|
|
423
512
|
loginState.set(provider, { done: false });
|
|
424
513
|
const abort = new AbortController();
|
|
425
514
|
loginAbort.set(provider, abort);
|
|
@@ -431,12 +520,15 @@ export async function startLoginFlow(provider: string, opts?: LoginOpts): Promis
|
|
|
431
520
|
resolve({ url, instructions });
|
|
432
521
|
},
|
|
433
522
|
onProgress: () => {},
|
|
523
|
+
// GUI fallback when the browser cannot hit the loopback callback server.
|
|
524
|
+
onManualCodeInput: (expectedState?: string) => waitForManualLoginCode(provider, abort.signal, expectedState),
|
|
434
525
|
signal: abort.signal,
|
|
435
526
|
};
|
|
436
527
|
// Background: runLogin persists the credential + upserts the provider entry to disk config.
|
|
437
528
|
runLogin(provider, ctrl, opts)
|
|
438
529
|
.then(() => {
|
|
439
530
|
loginAbort.delete(provider);
|
|
531
|
+
clearManualCodeSlot(provider);
|
|
440
532
|
loginState.set(provider, { done: true });
|
|
441
533
|
// Local-token import (grok-cli / Claude Code keychain) completes WITHOUT firing onAuth —
|
|
442
534
|
// resolve so the GUI call returns instead of hanging.
|
|
@@ -444,6 +536,7 @@ export async function startLoginFlow(provider: string, opts?: LoginOpts): Promis
|
|
|
444
536
|
})
|
|
445
537
|
.catch((e: unknown) => {
|
|
446
538
|
loginAbort.delete(provider);
|
|
539
|
+
clearManualCodeSlot(provider);
|
|
447
540
|
const msg = e instanceof Error ? e.message : String(e);
|
|
448
541
|
loginState.set(provider, { done: true, error: msg });
|
|
449
542
|
if (!urlResolved) reject(e);
|
package/src/oauth/types.ts
CHANGED
|
@@ -31,7 +31,7 @@ export interface ProviderAccountSet {
|
|
|
31
31
|
export interface OAuthController {
|
|
32
32
|
onAuth?(info: { url: string; instructions?: string }): void;
|
|
33
33
|
onProgress?(message: string): void;
|
|
34
|
-
onManualCodeInput?(): Promise<string>;
|
|
34
|
+
onManualCodeInput?(expectedState?: string): Promise<string>;
|
|
35
35
|
signal?: AbortSignal;
|
|
36
36
|
}
|
|
37
37
|
|
package/src/providers/derive.ts
CHANGED
|
@@ -48,6 +48,7 @@ export interface DerivedProviderPreset {
|
|
|
48
48
|
oauthProvider?: string;
|
|
49
49
|
dashboardUrl?: string;
|
|
50
50
|
note?: string;
|
|
51
|
+
keyOptional?: boolean;
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
export function listRegistryEntries(): readonly ProviderRegistryEntry[] {
|
|
@@ -69,6 +70,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
|
|
|
69
70
|
authMode: entry.authKind === "local" ? undefined : entry.authKind,
|
|
70
71
|
...(entry.keyOptional !== undefined ? { keyOptional: entry.keyOptional } : {}),
|
|
71
72
|
...(entry.modelSuffixBracketStrip !== undefined ? { modelSuffixBracketStrip: entry.modelSuffixBracketStrip } : {}),
|
|
73
|
+
...(entry.staticHeaders ? { headers: { ...entry.staticHeaders } } : {}),
|
|
72
74
|
...(entry.defaultModel ? { defaultModel: entry.defaultModel } : {}),
|
|
73
75
|
...(entry.models ? { models: [...entry.models] } : {}),
|
|
74
76
|
...(entry.liveModels !== undefined ? { liveModels: entry.liveModels } : {}),
|
|
@@ -193,6 +195,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
|
|
|
193
195
|
if (prov.escapeBuiltinToolNames === undefined && seed.escapeBuiltinToolNames !== undefined) prov.escapeBuiltinToolNames = seed.escapeBuiltinToolNames;
|
|
194
196
|
if (prov.keyOptional === undefined && seed.keyOptional !== undefined) prov.keyOptional = seed.keyOptional;
|
|
195
197
|
if (prov.modelSuffixBracketStrip === undefined && seed.modelSuffixBracketStrip !== undefined) prov.modelSuffixBracketStrip = seed.modelSuffixBracketStrip;
|
|
198
|
+
if (!prov.headers && seed.headers) prov.headers = { ...seed.headers };
|
|
196
199
|
}
|
|
197
200
|
|
|
198
201
|
export function deriveFeaturedProviderIds(): string[] {
|
|
@@ -227,6 +230,7 @@ function entryToPreset(entry: ProviderRegistryEntry): DerivedProviderPreset {
|
|
|
227
230
|
...(entry.authKind === "oauth" ? { oauthProvider: entry.oauthId ?? entry.id } : {}),
|
|
228
231
|
...(entry.dashboardUrl ? { dashboardUrl: entry.dashboardUrl } : {}),
|
|
229
232
|
...(entry.note ? { note: entry.note } : {}),
|
|
233
|
+
...(entry.keyOptional ? { keyOptional: true } : {}),
|
|
230
234
|
};
|
|
231
235
|
}
|
|
232
236
|
|