mixdog 0.7.7 → 0.7.11
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 +51 -0
- package/README.md +13 -10
- package/package.json +1 -1
- package/scripts/openai-oauth-catalog-smoke.mjs +53 -0
- package/setup/config-merge.mjs +0 -1
- package/setup/install.mjs +574 -338
- package/setup/mixdog-cli.mjs +30 -3
- package/setup/setup-server.mjs +11 -31
- package/setup/setup.html +3 -3
- package/setup/tui.mjs +35 -316
- package/src/agent/orchestrator/config.mjs +0 -1
- package/src/agent/orchestrator/providers/anthropic-oauth.mjs +2 -5
- package/src/agent/orchestrator/providers/anthropic.mjs +243 -86
- package/src/agent/orchestrator/providers/gemini.mjs +386 -31
- package/src/agent/orchestrator/providers/grok-oauth.mjs +2 -5
- package/src/agent/orchestrator/providers/model-catalog.mjs +146 -13
- package/src/agent/orchestrator/providers/openai-compat-stream.mjs +366 -0
- package/src/agent/orchestrator/providers/openai-compat.mjs +74 -30
- package/src/agent/orchestrator/providers/openai-oauth-ws.mjs +2 -1
- package/src/agent/orchestrator/providers/openai-oauth.mjs +59 -13
- package/src/agent/orchestrator/session/manager.mjs +18 -4
- package/src/agent/orchestrator/stall-policy.mjs +6 -0
- package/src/shared/config.mjs +1 -1
- package/src/shared/disable-claude-builtins.mjs +7 -4
- package/src/shared/llm/cost.mjs +2 -2
- package/src/shared/open-url.mjs +37 -0
- package/src/shared/seed.mjs +20 -3
- package/src/shared/user-data-guard.mjs +8 -2
- package/setup/wizard.mjs +0 -696
|
@@ -3,10 +3,16 @@ import { createHash } from 'crypto';
|
|
|
3
3
|
import { loadConfig } from '../config.mjs';
|
|
4
4
|
import { withRetry } from './retry-classifier.mjs';
|
|
5
5
|
import { sendViaWebSocket } from './openai-oauth-ws.mjs';
|
|
6
|
+
import {
|
|
7
|
+
consumeCompatChatCompletionStream,
|
|
8
|
+
consumeCompatResponsesStream,
|
|
9
|
+
parseCompletedToolCallArgumentsJson,
|
|
10
|
+
} from './openai-compat-stream.mjs';
|
|
6
11
|
import { appendBridgeTrace, traceBridgeUsage } from '../bridge-trace.mjs';
|
|
7
12
|
import { resolveProviderCacheKey } from '../smart-bridge/cache-strategy.mjs';
|
|
8
13
|
import {
|
|
9
14
|
PROVIDER_FIRST_BYTE_TIMEOUT_MS,
|
|
15
|
+
PROVIDER_NONSTREAM_TOTAL_TIMEOUT_MS,
|
|
10
16
|
PROVIDER_GENERATE_TOTAL_TIMEOUT_MS,
|
|
11
17
|
createTimeoutSignal,
|
|
12
18
|
resolveTimeoutMs,
|
|
@@ -23,9 +29,16 @@ export const OPENAI_COMPAT_PRESETS = {
|
|
|
23
29
|
baseURL: 'https://api.x.ai/v1',
|
|
24
30
|
defaultModel: 'grok-4.3',
|
|
25
31
|
},
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
32
|
+
// OpenCode Go — low-cost coding-model subscription gateway. The Go
|
|
33
|
+
// gateway exposes a unified OpenAI-compatible /chat/completions surface
|
|
34
|
+
// that transparently fronts every Go model (GLM / Kimi / DeepSeek / MiMo
|
|
35
|
+
// and the anthropic-native MiniMax / Qwen), including tool-calling and
|
|
36
|
+
// server-side prefix caching (cached_tokens), so no separate Anthropic
|
|
37
|
+
// transport is needed. Auth is a single OPENCODE_API_KEY (Bearer).
|
|
38
|
+
// listModels() pulls the live roster from {baseURL}/models.
|
|
39
|
+
'opencode-go': {
|
|
40
|
+
baseURL: 'https://opencode.ai/zen/go/v1',
|
|
41
|
+
defaultModel: 'glm-5.2',
|
|
29
42
|
},
|
|
30
43
|
ollama: {
|
|
31
44
|
baseURL: 'http://localhost:11434/v1',
|
|
@@ -841,7 +854,7 @@ function toResponsesTools(tools) {
|
|
|
841
854
|
parameters: t.inputSchema,
|
|
842
855
|
}));
|
|
843
856
|
}
|
|
844
|
-
function parseToolCalls(choice) {
|
|
857
|
+
function parseToolCalls(choice, label) {
|
|
845
858
|
const calls = choice.message?.tool_calls;
|
|
846
859
|
if (!calls?.length)
|
|
847
860
|
return undefined;
|
|
@@ -850,7 +863,7 @@ function parseToolCalls(choice) {
|
|
|
850
863
|
.map((tc) => ({
|
|
851
864
|
id: tc.id,
|
|
852
865
|
name: tc.function.name,
|
|
853
|
-
arguments:
|
|
866
|
+
arguments: parseCompletedToolCallArgumentsJson(tc.function.arguments, label),
|
|
854
867
|
}));
|
|
855
868
|
}
|
|
856
869
|
function parseJsonObject(value) {
|
|
@@ -861,14 +874,14 @@ function parseJsonObject(value) {
|
|
|
861
874
|
return {};
|
|
862
875
|
}
|
|
863
876
|
}
|
|
864
|
-
function parseResponsesToolCalls(response) {
|
|
877
|
+
function parseResponsesToolCalls(response, label) {
|
|
865
878
|
const out = [];
|
|
866
879
|
for (const item of response?.output || []) {
|
|
867
880
|
if (item?.type !== 'function_call') continue;
|
|
868
881
|
out.push({
|
|
869
882
|
id: item.call_id || item.id,
|
|
870
883
|
name: item.name,
|
|
871
|
-
arguments:
|
|
884
|
+
arguments: parseCompletedToolCallArgumentsJson(item.arguments, label),
|
|
872
885
|
});
|
|
873
886
|
}
|
|
874
887
|
return out.length ? out : undefined;
|
|
@@ -1025,7 +1038,7 @@ export class OpenAICompatProvider {
|
|
|
1025
1038
|
?? process.env.MIXDOG_XAI_REASONING_EFFORT);
|
|
1026
1039
|
if (reasoningEffort) params.reasoning_effort = reasoningEffort;
|
|
1027
1040
|
}
|
|
1028
|
-
const totalSignal = createTimeoutSignal(signal,
|
|
1041
|
+
const totalSignal = createTimeoutSignal(signal, PROVIDER_NONSTREAM_TOTAL_TIMEOUT_MS, `${this.name} total`);
|
|
1029
1042
|
const cacheRouting = this.name === 'xai'
|
|
1030
1043
|
? xaiCacheRouting(opts, params, tools || [], useModel)
|
|
1031
1044
|
: null;
|
|
@@ -1040,14 +1053,31 @@ export class OpenAICompatProvider {
|
|
|
1040
1053
|
// their own load balancers and emit 5xx / "overloaded" under burst
|
|
1041
1054
|
// traffic. The withRetry wrapper preserves abort behavior via
|
|
1042
1055
|
// mergedSignal and only retries when classifyError() says transient.
|
|
1043
|
-
|
|
1056
|
+
params.stream = true;
|
|
1057
|
+
params.stream_options = { include_usage: true };
|
|
1058
|
+
let assembled;
|
|
1044
1059
|
try {
|
|
1045
|
-
|
|
1046
|
-
({ signal: attemptSignal }) =>
|
|
1060
|
+
assembled = await withRetry(
|
|
1061
|
+
async ({ signal: attemptSignal }) => {
|
|
1062
|
+
try { opts.onStageChange?.('requesting'); } catch { /* heartbeat best-effort */ }
|
|
1063
|
+
const stream = await withRetry(
|
|
1064
|
+
({ signal: openSignal }) => this.client.chat.completions.create(params, { signal: openSignal }),
|
|
1065
|
+
{
|
|
1066
|
+
signal: attemptSignal,
|
|
1067
|
+
perAttemptTimeoutMs: PROVIDER_FIRST_BYTE_TIMEOUT_MS,
|
|
1068
|
+
perAttemptLabel: `${this.name} first byte`,
|
|
1069
|
+
},
|
|
1070
|
+
);
|
|
1071
|
+
try { opts.onStageChange?.('streaming'); } catch { /* heartbeat best-effort */ }
|
|
1072
|
+
return consumeCompatChatCompletionStream(stream, {
|
|
1073
|
+
signal: attemptSignal,
|
|
1074
|
+
label: this.name,
|
|
1075
|
+
onStreamDelta: opts.onStreamDelta,
|
|
1076
|
+
parseToolCalls,
|
|
1077
|
+
});
|
|
1078
|
+
},
|
|
1047
1079
|
{
|
|
1048
1080
|
signal: totalSignal.signal,
|
|
1049
|
-
perAttemptTimeoutMs: PROVIDER_FIRST_BYTE_TIMEOUT_MS,
|
|
1050
|
-
perAttemptLabel: `${this.name} first byte`,
|
|
1051
1081
|
onRetry: ({ attempt, lastErr, delayMs, delayReason }) => {
|
|
1052
1082
|
const delayLabel = Number.isFinite(Number(delayMs)) ? `, delay ${delayMs}ms${delayReason ? ` (${delayReason})` : ''}` : '';
|
|
1053
1083
|
process.stderr.write(`[${this.name}] retry attempt ${attempt + 1} after ${lastErr?.message || lastErr?.code || 'transient error'}${delayLabel}\n`);
|
|
@@ -1057,8 +1087,9 @@ export class OpenAICompatProvider {
|
|
|
1057
1087
|
} finally {
|
|
1058
1088
|
totalSignal.cleanup();
|
|
1059
1089
|
}
|
|
1090
|
+
const response = assembled.response;
|
|
1060
1091
|
const choice = response.choices[0];
|
|
1061
|
-
const toolCalls =
|
|
1092
|
+
const toolCalls = assembled.toolCalls;
|
|
1062
1093
|
// Capture finish_reason early so we can refuse to return an
|
|
1063
1094
|
// incomplete completion as final content. OpenAI-compat backends use
|
|
1064
1095
|
// `length` (max_tokens / model context overflow) and `content_filter`
|
|
@@ -1115,11 +1146,11 @@ export class OpenAICompatProvider {
|
|
|
1115
1146
|
// assistant message and echo it back next turn for providers that
|
|
1116
1147
|
// require or benefit from that official multi-turn shape.
|
|
1117
1148
|
const capturesReasoningContent = this.name === 'deepseek' || this.name === 'xai';
|
|
1118
|
-
const reasoningContent = (capturesReasoningContent && typeof
|
|
1119
|
-
?
|
|
1149
|
+
const reasoningContent = (capturesReasoningContent && typeof assembled.reasoningContent === 'string')
|
|
1150
|
+
? assembled.reasoningContent
|
|
1120
1151
|
: null;
|
|
1121
1152
|
return {
|
|
1122
|
-
content:
|
|
1153
|
+
content: assembled.content || '',
|
|
1123
1154
|
model: response.model,
|
|
1124
1155
|
toolCalls,
|
|
1125
1156
|
stopReason,
|
|
@@ -1165,17 +1196,15 @@ export class OpenAICompatProvider {
|
|
|
1165
1196
|
};
|
|
1166
1197
|
if (previousResponseId) params.previous_response_id = previousResponseId;
|
|
1167
1198
|
if (tools?.length) params.tools = toResponsesTools(tools);
|
|
1168
|
-
//
|
|
1169
|
-
//
|
|
1170
|
-
// 'connecting' reset for the whole generation (bridge list shows a
|
|
1171
|
-
// working session as stuck). Report 'requesting' for the in-flight
|
|
1172
|
-
// window and fire one delta on arrival to feed the stall watchdog.
|
|
1199
|
+
// SSE transport: report 'requesting' until the stream opens, then
|
|
1200
|
+
// per-chunk onStreamDelta feeds the bridge stall watchdog.
|
|
1173
1201
|
try { opts.onStageChange?.('requesting'); } catch { /* heartbeat best-effort */ }
|
|
1174
1202
|
const reasoningEffort = normalizeXaiReasoningEffort(opts.xaiReasoningEffort
|
|
1175
1203
|
?? opts.effort
|
|
1176
1204
|
?? this.config?.reasoningEffort
|
|
1177
1205
|
?? process.env.MIXDOG_XAI_REASONING_EFFORT);
|
|
1178
1206
|
if (reasoningEffort) params.reasoning = { effort: reasoningEffort };
|
|
1207
|
+
params.stream = true;
|
|
1179
1208
|
let response;
|
|
1180
1209
|
let cacheLane = null;
|
|
1181
1210
|
const scheduled = await withXaiResponsesCacheLane({
|
|
@@ -1189,14 +1218,29 @@ export class OpenAICompatProvider {
|
|
|
1189
1218
|
signal,
|
|
1190
1219
|
}, async (laneMeta) => {
|
|
1191
1220
|
cacheLane = laneMeta;
|
|
1192
|
-
const totalSignal = createTimeoutSignal(signal,
|
|
1221
|
+
const totalSignal = createTimeoutSignal(signal, PROVIDER_NONSTREAM_TOTAL_TIMEOUT_MS, 'xai responses total');
|
|
1193
1222
|
try {
|
|
1194
1223
|
return await withRetry(
|
|
1195
|
-
({ signal: attemptSignal }) =>
|
|
1224
|
+
async ({ signal: attemptSignal }) => {
|
|
1225
|
+
const stream = await withRetry(
|
|
1226
|
+
({ signal: openSignal }) => this.client.responses.create(params, { signal: openSignal }),
|
|
1227
|
+
{
|
|
1228
|
+
signal: attemptSignal,
|
|
1229
|
+
perAttemptTimeoutMs: PROVIDER_FIRST_BYTE_TIMEOUT_MS,
|
|
1230
|
+
perAttemptLabel: 'xai responses first byte',
|
|
1231
|
+
},
|
|
1232
|
+
);
|
|
1233
|
+
try { opts.onStageChange?.('streaming'); } catch { /* heartbeat best-effort */ }
|
|
1234
|
+
return consumeCompatResponsesStream(stream, {
|
|
1235
|
+
signal: attemptSignal,
|
|
1236
|
+
label: 'xai:responses',
|
|
1237
|
+
onStreamDelta: opts.onStreamDelta,
|
|
1238
|
+
parseResponsesToolCalls,
|
|
1239
|
+
responseOutputText,
|
|
1240
|
+
});
|
|
1241
|
+
},
|
|
1196
1242
|
{
|
|
1197
1243
|
signal: totalSignal.signal,
|
|
1198
|
-
perAttemptTimeoutMs: PROVIDER_FIRST_BYTE_TIMEOUT_MS,
|
|
1199
|
-
perAttemptLabel: 'xai responses first byte',
|
|
1200
1244
|
onRetry: ({ attempt, lastErr, delayMs, delayReason }) => {
|
|
1201
1245
|
const delayLabel = Number.isFinite(Number(delayMs)) ? `, delay ${delayMs}ms${delayReason ? ` (${delayReason})` : ''}` : '';
|
|
1202
1246
|
process.stderr.write(`[xai:responses] retry attempt ${attempt + 1} after ${lastErr?.message || lastErr?.code || 'transient error'}${delayLabel}\n`);
|
|
@@ -1207,10 +1251,10 @@ export class OpenAICompatProvider {
|
|
|
1207
1251
|
totalSignal.cleanup();
|
|
1208
1252
|
}
|
|
1209
1253
|
});
|
|
1210
|
-
|
|
1254
|
+
const streamed = scheduled.value;
|
|
1255
|
+
response = streamed.response;
|
|
1211
1256
|
cacheLane = cacheLane || scheduled.laneMeta;
|
|
1212
|
-
|
|
1213
|
-
const toolCalls = parseResponsesToolCalls(response);
|
|
1257
|
+
const toolCalls = streamed.toolCalls;
|
|
1214
1258
|
writeXaiResponsesCacheTrace({
|
|
1215
1259
|
model: useModel,
|
|
1216
1260
|
opts,
|
|
@@ -1254,7 +1298,7 @@ export class OpenAICompatProvider {
|
|
|
1254
1298
|
});
|
|
1255
1299
|
}
|
|
1256
1300
|
return {
|
|
1257
|
-
content:
|
|
1301
|
+
content: streamed.content,
|
|
1258
1302
|
model: response.model || useModel,
|
|
1259
1303
|
toolCalls,
|
|
1260
1304
|
providerState: {
|
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
} from '../stall-policy.mjs';
|
|
44
44
|
|
|
45
45
|
const CODEX_WS_URL = 'wss://chatgpt.com/backend-api/codex/responses';
|
|
46
|
+
const CODEX_OAUTH_ORIGINATOR = 'codex_cli_rs';
|
|
46
47
|
const OPENAI_WS_URL = 'wss://api.openai.com/v1/responses';
|
|
47
48
|
const XAI_WS_URL = 'wss://api.x.ai/v1/responses';
|
|
48
49
|
const WS_IDLE_MS = 5 * 60_000;
|
|
@@ -202,7 +203,7 @@ function _buildHandshakeHeaders({ auth, sessionToken, turnState, cacheKey }) {
|
|
|
202
203
|
: {
|
|
203
204
|
'Authorization': `Bearer ${auth.access_token}`,
|
|
204
205
|
'chatgpt-account-id': auth.account_id || '',
|
|
205
|
-
'originator':
|
|
206
|
+
'originator': CODEX_OAUTH_ORIGINATOR,
|
|
206
207
|
'OpenAI-Beta': 'responses_websockets=2026-02-06',
|
|
207
208
|
};
|
|
208
209
|
if (sessionToken) {
|
|
@@ -33,6 +33,7 @@ import { populateHttpStatusFromMessage } from './retry-classifier.mjs';
|
|
|
33
33
|
import { getLlmDispatcher, preconnect } from '../../../shared/llm/http-agent.mjs';
|
|
34
34
|
// --- Constants ---
|
|
35
35
|
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
|
36
|
+
const CODEX_OAUTH_ORIGINATOR = 'codex_cli_rs';
|
|
36
37
|
const TOKEN_URL = 'https://auth.openai.com/oauth/token';
|
|
37
38
|
const CODEX_RESPONSES_URL = 'https://chatgpt.com/backend-api/codex/responses';
|
|
38
39
|
// Version string baked into the models endpoint query — Codex rejects the
|
|
@@ -69,26 +70,33 @@ async function _resolveCodexClientVersion() {
|
|
|
69
70
|
return CODEX_CLIENT_VERSION_FLOOR;
|
|
70
71
|
}
|
|
71
72
|
const CODEX_MODEL_CACHE_TTL_MS = 24 * 60 * 60_000;
|
|
73
|
+
const CODEX_MODEL_CACHE_SCHEMA_VERSION = 2;
|
|
72
74
|
const TOKEN_REFRESH_SKEW_MS = 5 * 60_000;
|
|
73
75
|
|
|
74
76
|
function _codexModelCachePath() {
|
|
75
77
|
return join(getPluginData(), 'openai-oauth-models.json');
|
|
76
78
|
}
|
|
77
79
|
|
|
78
|
-
|
|
80
|
+
function _loadCodexModelCacheSync() {
|
|
79
81
|
const path = _codexModelCachePath();
|
|
80
82
|
if (!existsSync(path)) return null;
|
|
81
83
|
try {
|
|
82
84
|
const raw = JSON.parse(readFileSync(path, 'utf-8'));
|
|
85
|
+
if (raw?.version !== CODEX_MODEL_CACHE_SCHEMA_VERSION) return null;
|
|
83
86
|
if (!raw?.fetchedAt || !Array.isArray(raw.models)) return null;
|
|
84
87
|
if (Date.now() - raw.fetchedAt > CODEX_MODEL_CACHE_TTL_MS) return null;
|
|
85
88
|
return raw.models;
|
|
86
89
|
} catch { return null; }
|
|
87
90
|
}
|
|
88
91
|
|
|
92
|
+
async function _loadCodexModelCache() {
|
|
93
|
+
return _loadCodexModelCacheSync();
|
|
94
|
+
}
|
|
95
|
+
|
|
89
96
|
async function _saveCodexModelCache(models) {
|
|
90
97
|
try {
|
|
91
98
|
writeJsonAtomicSync(_codexModelCachePath(), {
|
|
99
|
+
version: CODEX_MODEL_CACHE_SCHEMA_VERSION,
|
|
92
100
|
fetchedAt: Date.now(),
|
|
93
101
|
models,
|
|
94
102
|
}, { lock: true, fsyncDir: true });
|
|
@@ -112,6 +120,27 @@ function _codexCatalogHas(id) {
|
|
|
112
120
|
return _inMemoryCodexCatalog.some(m => m.id === id);
|
|
113
121
|
}
|
|
114
122
|
|
|
123
|
+
function _findCachedCodexModel(id) {
|
|
124
|
+
if (!id) return null;
|
|
125
|
+
if (!Array.isArray(_inMemoryCodexCatalog)) {
|
|
126
|
+
_inMemoryCodexCatalog = _loadCodexModelCacheSync();
|
|
127
|
+
}
|
|
128
|
+
if (!Array.isArray(_inMemoryCodexCatalog)) return null;
|
|
129
|
+
return _inMemoryCodexCatalog.find(m => m?.id === id) || null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function _codexServiceTiers(modelInfo) {
|
|
133
|
+
return Array.isArray(modelInfo?.serviceTiers) ? modelInfo.serviceTiers : [];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function _codexModelSupportsServiceTier(id, serviceTier) {
|
|
137
|
+
const info = _findCachedCodexModel(id);
|
|
138
|
+
if (!info) return true;
|
|
139
|
+
const tiers = _codexServiceTiers(info);
|
|
140
|
+
if (!tiers.length) return false;
|
|
141
|
+
return tiers.some(t => t?.id === serviceTier);
|
|
142
|
+
}
|
|
143
|
+
|
|
115
144
|
// Codex returns dated ids (gpt-5.4-mini-2026-03-17). Strip the trailing
|
|
116
145
|
// -YYYY-MM-DD to get the version alias (gpt-5.4-mini). Unknown shapes pass
|
|
117
146
|
// through unchanged.
|
|
@@ -123,6 +152,18 @@ function _displayCodexModel(id) {
|
|
|
123
152
|
function _normalizeCodexModel(m) {
|
|
124
153
|
const id = m?.slug || m?.id;
|
|
125
154
|
const family = _codexFamily(id);
|
|
155
|
+
const serviceTiers = Array.isArray(m?.service_tiers)
|
|
156
|
+
? m.service_tiers
|
|
157
|
+
.map(t => ({
|
|
158
|
+
id: String(t?.id || '').trim(),
|
|
159
|
+
name: String(t?.name || '').trim(),
|
|
160
|
+
description: String(t?.description || '').trim(),
|
|
161
|
+
}))
|
|
162
|
+
.filter(t => t.id)
|
|
163
|
+
: [];
|
|
164
|
+
const additionalSpeedTiers = Array.isArray(m?.additional_speed_tiers)
|
|
165
|
+
? m.additional_speed_tiers.map(t => String(t || '').trim()).filter(Boolean)
|
|
166
|
+
: [];
|
|
126
167
|
// Codex doesn't use dated ids — everything is effectively a version alias.
|
|
127
168
|
return {
|
|
128
169
|
id,
|
|
@@ -130,12 +171,17 @@ function _normalizeCodexModel(m) {
|
|
|
130
171
|
display: m?.display_name || id,
|
|
131
172
|
family,
|
|
132
173
|
provider: 'openai-oauth',
|
|
133
|
-
contextWindow: m?.context_window || 1000000,
|
|
174
|
+
contextWindow: m?.context_window || m?.max_context_window || 1000000,
|
|
175
|
+
maxContextWindow: m?.max_context_window || null,
|
|
134
176
|
outputTokens: m?.auto_compact_token_limit || 32768,
|
|
177
|
+
autoCompactTokenLimit: m?.auto_compact_token_limit || null,
|
|
135
178
|
tier: 'version',
|
|
136
179
|
latest: false,
|
|
137
180
|
description: m?.description || '',
|
|
138
181
|
reasoningLevels: (m?.supported_reasoning_levels || []).map(r => r.effort),
|
|
182
|
+
serviceTiers,
|
|
183
|
+
defaultServiceTier: m?.default_service_tier || null,
|
|
184
|
+
additionalSpeedTiers,
|
|
139
185
|
};
|
|
140
186
|
}
|
|
141
187
|
|
|
@@ -482,10 +528,11 @@ export function buildRequestBody(messages, model, tools, sendOpts) {
|
|
|
482
528
|
if (opts.fast === true) {
|
|
483
529
|
// 'priority' is the only fast-class value the Codex OAuth backend
|
|
484
530
|
// accepts on the wire: 'fast' is hard-rejected ("Unsupported
|
|
485
|
-
// service_tier: fast", probed 2026-06-11)
|
|
486
|
-
//
|
|
487
|
-
|
|
488
|
-
|
|
531
|
+
// service_tier: fast", probed 2026-06-11). Match official Codex:
|
|
532
|
+
// only send the request value when the model catalog advertises it.
|
|
533
|
+
if (_codexModelSupportsServiceTier(model, 'priority')) {
|
|
534
|
+
body.service_tier = 'priority';
|
|
535
|
+
}
|
|
489
536
|
}
|
|
490
537
|
// Add tools
|
|
491
538
|
if (tools?.length) {
|
|
@@ -564,7 +611,7 @@ function _buildOpenAIHttpFallbackHeaders({ auth, cacheKey }) {
|
|
|
564
611
|
'Content-Type': 'application/json',
|
|
565
612
|
Accept: 'text/event-stream',
|
|
566
613
|
'OpenAI-Beta': 'responses=experimental',
|
|
567
|
-
originator:
|
|
614
|
+
originator: CODEX_OAUTH_ORIGINATOR,
|
|
568
615
|
'chatgpt-account-id': auth.account_id || '',
|
|
569
616
|
'x-client-request-id': randomBytes(16).toString('hex'),
|
|
570
617
|
};
|
|
@@ -954,6 +1001,9 @@ export class OpenAIOAuthProvider {
|
|
|
954
1001
|
// request skips the cold TLS handshake. Best-effort; never throws.
|
|
955
1002
|
preconnect('https://chatgpt.com');
|
|
956
1003
|
}
|
|
1004
|
+
getCachedModelInfo(model) {
|
|
1005
|
+
return _findCachedCodexModel(model);
|
|
1006
|
+
}
|
|
957
1007
|
async ensureAuth({ forceRefresh = false, reason = 'preemptive' } = {}) {
|
|
958
1008
|
if (!this.tokens) this.tokens = loadTokens();
|
|
959
1009
|
if (!this.tokens)
|
|
@@ -1308,7 +1358,6 @@ export class OpenAIOAuthProvider {
|
|
|
1308
1358
|
|
|
1309
1359
|
const AUTHORIZE_URL = 'https://auth.openai.com/oauth/authorize';
|
|
1310
1360
|
const CODEX_OAUTH_SCOPE = 'openid profile email offline_access api.connectors.read api.connectors.invoke';
|
|
1311
|
-
const CODEX_OAUTH_ORIGINATOR = 'codex_cli_rs';
|
|
1312
1361
|
const CALLBACK_HOST = '127.0.0.1';
|
|
1313
1362
|
const CALLBACK_PORT = 1455;
|
|
1314
1363
|
const CALLBACK_PATH = '/auth/callback';
|
|
@@ -1337,11 +1386,8 @@ export async function loginOAuth() {
|
|
|
1337
1386
|
url.searchParams.set('state', state);
|
|
1338
1387
|
url.searchParams.set('originator', CODEX_OAUTH_ORIGINATOR);
|
|
1339
1388
|
process.stderr.write(`\n[openai-oauth] Open this URL to log in to ChatGPT (Codex):\n${url.toString()}\n\n`);
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
const opener = process.platform === 'win32' ? 'start' : process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
1343
|
-
exec(`${opener} "${url.toString()}"`, { windowsHide: true });
|
|
1344
|
-
} catch { /* user opens manually */ }
|
|
1389
|
+
const { openInBrowser } = await import('../../../shared/open-url.mjs');
|
|
1390
|
+
openInBrowser(url.toString());
|
|
1345
1391
|
|
|
1346
1392
|
return new Promise((resolve) => {
|
|
1347
1393
|
const timeout = setTimeout(() => { server.close(); resolve(null); }, LOGIN_TIMEOUT_MS);
|
|
@@ -357,9 +357,10 @@ let nextId = Date.now();
|
|
|
357
357
|
// without buying anything.
|
|
358
358
|
const CONTEXT_WINDOWS = {
|
|
359
359
|
// OpenAI GPT-5.x family
|
|
360
|
-
'gpt-5.5':
|
|
361
|
-
'gpt-5.4
|
|
362
|
-
'gpt-5.4-
|
|
360
|
+
'gpt-5.5': 272000,
|
|
361
|
+
'gpt-5.4': 272000,
|
|
362
|
+
'gpt-5.4-mini': 272000,
|
|
363
|
+
'gpt-5.4-nano': 272000,
|
|
363
364
|
// Anthropic Claude 4.x
|
|
364
365
|
'claude-opus-4-8': 1000000,
|
|
365
366
|
'claude-opus-4-7': 1000000,
|
|
@@ -378,6 +379,18 @@ function guessContextWindow(model) {
|
|
|
378
379
|
return 8192;
|
|
379
380
|
return 128000;
|
|
380
381
|
}
|
|
382
|
+
function positiveContextWindow(value) {
|
|
383
|
+
const n = Number(value);
|
|
384
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
|
|
385
|
+
}
|
|
386
|
+
function resolveSessionContextWindow(provider, model) {
|
|
387
|
+
const info = typeof provider?.getCachedModelInfo === 'function'
|
|
388
|
+
? provider.getCachedModelInfo(model)
|
|
389
|
+
: null;
|
|
390
|
+
return positiveContextWindow(info?.contextWindow)
|
|
391
|
+
|| positiveContextWindow(info?.context_window)
|
|
392
|
+
|| guessContextWindow(model);
|
|
393
|
+
}
|
|
381
394
|
// Provider-scoped unified cache key. Goal: all orchestrator-internal
|
|
382
395
|
// dispatches (bridge/maintenance/mcp/scheduler/webhook) targeting the
|
|
383
396
|
// same provider land in a single server-side cache shard, so the
|
|
@@ -902,7 +915,7 @@ export function createSession(opts) {
|
|
|
902
915
|
provider: providerName,
|
|
903
916
|
model: modelName,
|
|
904
917
|
messages,
|
|
905
|
-
contextWindow:
|
|
918
|
+
contextWindow: resolveSessionContextWindow(provider, modelName),
|
|
906
919
|
tools,
|
|
907
920
|
preset: toolPreset,
|
|
908
921
|
presetName: presetObj?.name || null,
|
|
@@ -1417,6 +1430,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
1417
1430
|
runtime.session = session;
|
|
1418
1431
|
if (!provider)
|
|
1419
1432
|
throw new Error(`Provider "${session.provider}" not available`);
|
|
1433
|
+
session.contextWindow = resolveSessionContextWindow(provider, session.model);
|
|
1420
1434
|
// Cap caller-supplied / prefetched context so an oversized
|
|
1421
1435
|
// payload can't blow the session token budget before the
|
|
1422
1436
|
// first model call. 32 KB ~ 8k tokens at the 4 B/tok
|
|
@@ -70,6 +70,12 @@ export const PROVIDER_GENERATE_TOTAL_TIMEOUT_MS = resolveTimeoutMs(
|
|
|
70
70
|
{ minMs: PROVIDER_FIRST_BYTE_TIMEOUT_MS, maxMs: PROVIDER_MAX_BEFORE_WARN_MS },
|
|
71
71
|
);
|
|
72
72
|
|
|
73
|
+
export const PROVIDER_NONSTREAM_TOTAL_TIMEOUT_MS = resolveTimeoutMs(
|
|
74
|
+
['MIXDOG_NONSTREAM_TOTAL_TIMEOUT_MS', 'MIXDOG_COMPAT_NONSTREAM_TOTAL_TIMEOUT_MS'],
|
|
75
|
+
480_000,
|
|
76
|
+
{ minMs: PROVIDER_GENERATE_TOTAL_TIMEOUT_MS, maxMs: STALL_ABORT_MS },
|
|
77
|
+
);
|
|
78
|
+
|
|
73
79
|
export const PROVIDER_CACHE_CREATE_TIMEOUT_MS = resolveTimeoutMs(
|
|
74
80
|
'MIXDOG_PROVIDER_CACHE_CREATE_TIMEOUT_MS',
|
|
75
81
|
Math.min(120_000, PROVIDER_GENERATE_TOTAL_TIMEOUT_MS),
|
package/src/shared/config.mjs
CHANGED
|
@@ -299,7 +299,7 @@ export function getSearchApiKey(provider) {
|
|
|
299
299
|
// exports keep working, then MIXDOG_AGENT_<P>_APIKEY, then the OS keychain.
|
|
300
300
|
const AGENT_PROVIDER_ENV = Object.freeze({
|
|
301
301
|
openai: 'OPENAI_API_KEY', anthropic: 'ANTHROPIC_API_KEY', gemini: 'GEMINI_API_KEY',
|
|
302
|
-
deepseek: 'DEEPSEEK_API_KEY', xai: 'XAI_API_KEY',
|
|
302
|
+
deepseek: 'DEEPSEEK_API_KEY', xai: 'XAI_API_KEY',
|
|
303
303
|
})
|
|
304
304
|
|
|
305
305
|
// Last-resort env aliases honored AFTER the standard env / MIXDOG_AGENT_* /
|
|
@@ -11,12 +11,15 @@ import { getBackupRoot } from './user-data-guard.mjs';
|
|
|
11
11
|
// behaviour can be restored. The createOnly gate in seed.mjs guarantees this
|
|
12
12
|
// runs exactly once, so we never reapply on later boots.
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
function claudeConfigBaseDir() {
|
|
15
|
+
return process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Settings path is a parameter (defaulting to <CLAUDE_CONFIG_DIR or ~/.claude>/settings.json)
|
|
19
|
+
// so it can be redirected via MIXDOG_CLAUDE_SETTINGS_PATH for testing without a real homedir.
|
|
17
20
|
export function resolveClaudeSettingsPath() {
|
|
18
21
|
return process.env.MIXDOG_CLAUDE_SETTINGS_PATH
|
|
19
|
-
|| join(
|
|
22
|
+
|| join(claudeConfigBaseDir(), 'settings.json');
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
function readJsonOrNull(filePath) {
|
package/src/shared/llm/cost.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { getModelMetadataSync } from '../../agent/orchestrator/providers/model-c
|
|
|
17
17
|
// count *including* the cached portion (inclusive). Anthropic reports the
|
|
18
18
|
// uncached remainder only and bills cached_read / cached_write as separate
|
|
19
19
|
// additive slots (additive). Cost and prompt-total math has to branch on this.
|
|
20
|
-
// OpenAI-compatible direct providers (deepseek /
|
|
20
|
+
// OpenAI-compatible direct providers (deepseek / ollama / lmstudio)
|
|
21
21
|
// go through the OpenAI SDK and likewise report an inclusive prompt_tokens
|
|
22
22
|
// with a separate cached-tokens detail — so they are inclusive too. Omitting
|
|
23
23
|
// them bills the cached portion at the full input rate AND re-adds it as a
|
|
@@ -31,7 +31,7 @@ export function isInclusiveProvider(provider) {
|
|
|
31
31
|
// usage rows — without it, cached tokens would be double-billed in the
|
|
32
32
|
// cost fallback and prompt totals.
|
|
33
33
|
return p.includes('openai') || p.includes('codex') || p.includes('gemini') || p.includes('google') || p.includes('xai') || p.includes('grok')
|
|
34
|
-
|| p.includes('deepseek') || p.includes('
|
|
34
|
+
|| p.includes('deepseek') || p.includes('ollama') || p.includes('lmstudio') || p.includes('groq') || p.includes('openrouter');
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/**
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Open a URL in the user's default browser. Best-effort and non-blocking —
|
|
5
|
+
* the caller always prints the URL too, so a failure here is never fatal.
|
|
6
|
+
*
|
|
7
|
+
* Platform dispatch (deterministic, not heuristic):
|
|
8
|
+
* - Windows: `rundll32 url.dll,FileProtocolHandler <url>` passes the URL as a
|
|
9
|
+
* single argv token, so query-string `&` separators are NOT re-parsed by a
|
|
10
|
+
* shell. The old `start "<url>"` form is broken on Windows: cmd's `start`
|
|
11
|
+
* treats the first quoted string as the WINDOW TITLE, so the URL is dropped
|
|
12
|
+
* and nothing opens.
|
|
13
|
+
* - macOS: `open <url>`.
|
|
14
|
+
* - Linux/other: `xdg-open <url>`.
|
|
15
|
+
*/
|
|
16
|
+
export function openInBrowser(url) {
|
|
17
|
+
const u = String(url);
|
|
18
|
+
let cmd;
|
|
19
|
+
let args;
|
|
20
|
+
if (process.platform === 'win32') {
|
|
21
|
+
cmd = 'rundll32';
|
|
22
|
+
args = ['url.dll,FileProtocolHandler', u];
|
|
23
|
+
} else if (process.platform === 'darwin') {
|
|
24
|
+
cmd = 'open';
|
|
25
|
+
args = [u];
|
|
26
|
+
} else {
|
|
27
|
+
cmd = 'xdg-open';
|
|
28
|
+
args = [u];
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true, windowsHide: true });
|
|
32
|
+
child.on('error', () => { /* opener missing — user uses the printed URL */ });
|
|
33
|
+
child.unref();
|
|
34
|
+
} catch {
|
|
35
|
+
/* spawn threw synchronously — user opens the printed URL manually */
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/shared/seed.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { dirname, join } from 'path';
|
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
4
|
import { DEFAULT_PRESETS, DEFAULT_MAINTENANCE } from '../agent/orchestrator/config.mjs';
|
|
5
5
|
import { writeFileAtomicSync, withFileLockSync } from './atomic-file.mjs';
|
|
6
|
-
import { backupUserData, markUserDataInitialized, shouldSeedMissingUserData } from './user-data-guard.mjs';
|
|
6
|
+
import { backupUserData, hasUserDataInitMarker, markUserDataInitialized, shouldSeedMissingUserData } from './user-data-guard.mjs';
|
|
7
7
|
import { disableClaudeBuiltinsOnFirstInstall } from './disable-claude-builtins.mjs';
|
|
8
8
|
|
|
9
9
|
const DEFAULTS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'defaults');
|
|
@@ -49,12 +49,27 @@ const SEEDS = {
|
|
|
49
49
|
};
|
|
50
50
|
return JSON.stringify(composed, null, 2) + '\n';
|
|
51
51
|
},
|
|
52
|
+
// Role→preset mapping consumed by Smart Bridge (loadResolvedRoles); without
|
|
53
|
+
// it on disk bridge roles fall back to the default preset. Baseline Lead
|
|
54
|
+
// workflow description ships alongside. Seeded HERE (not in setup-server)
|
|
55
|
+
// so this is the single first-install SSOT and the init marker is set once,
|
|
56
|
+
// after the whole default set lands.
|
|
57
|
+
'user-workflow.json': () => readFileSync(join(DEFAULTS_DIR, 'user-workflow.json'), 'utf8'),
|
|
58
|
+
'user-workflow.md': () => readFileSync(join(DEFAULTS_DIR, 'user-workflow.md'), 'utf8'),
|
|
52
59
|
};
|
|
53
60
|
|
|
54
61
|
export function ensureDataSeeds(dataDir) {
|
|
55
62
|
if (!dataDir) return { created: [], skipped: [] };
|
|
56
63
|
const created = [];
|
|
57
64
|
const skipped = [];
|
|
65
|
+
// Capture fresh-install state ONCE, before the loop. The per-file
|
|
66
|
+
// markUserDataInitialized() below sets the marker as soon as the first seed
|
|
67
|
+
// lands; if we re-consulted the guard per file, every SUBSEQUENT first-time
|
|
68
|
+
// seed in this same pass would be refused (treated as a post-init deletion),
|
|
69
|
+
// which is exactly how user-workflow.json could end up permanently missing.
|
|
70
|
+
// On a fresh dir we seed the whole default set; once initialized, the guard
|
|
71
|
+
// governs (never recreate a file the user deleted on purpose).
|
|
72
|
+
const freshInstall = !hasUserDataInitMarker(dataDir);
|
|
58
73
|
for (const [rel, bodyFn] of Object.entries(SEEDS)) {
|
|
59
74
|
const full = join(dataDir, rel);
|
|
60
75
|
if (existsSync(full)) {
|
|
@@ -62,7 +77,7 @@ export function ensureDataSeeds(dataDir) {
|
|
|
62
77
|
skipped.push(rel);
|
|
63
78
|
continue;
|
|
64
79
|
}
|
|
65
|
-
if (!shouldSeedMissingUserData(dataDir, rel)) {
|
|
80
|
+
if (!freshInstall && !shouldSeedMissingUserData(dataDir, rel)) {
|
|
66
81
|
skipped.push(rel);
|
|
67
82
|
continue;
|
|
68
83
|
}
|
|
@@ -112,7 +127,9 @@ export function ensureDataSeeds(dataDir) {
|
|
|
112
127
|
}
|
|
113
128
|
}
|
|
114
129
|
if (created.length > 0) {
|
|
115
|
-
process.
|
|
130
|
+
if (process.env.MIXDOG_SETUP_QUIET !== '1') {
|
|
131
|
+
process.stderr.write(`[seed] created ${created.length} file(s): ${created.join(', ')}\n`);
|
|
132
|
+
}
|
|
116
133
|
try { backupUserData(dataDir, 'post-seed'); } catch {}
|
|
117
134
|
}
|
|
118
135
|
return { created, skipped };
|
|
@@ -12,9 +12,13 @@ import { dirname, join, resolve } from 'path';
|
|
|
12
12
|
import { homedir } from 'os';
|
|
13
13
|
import { createHash } from 'crypto';
|
|
14
14
|
|
|
15
|
+
function claudeConfigBaseDir() {
|
|
16
|
+
return process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
|
|
17
|
+
}
|
|
18
|
+
|
|
15
19
|
export function getBackupRoot() {
|
|
16
20
|
return process.env.MIXDOG_USER_DATA_BACKUP_ROOT
|
|
17
|
-
|| join(
|
|
21
|
+
|| join(claudeConfigBaseDir(), 'backups', 'mixdog-user-data');
|
|
18
22
|
}
|
|
19
23
|
const RECOVERY_NOTICE = 'RECOVERY-REQUIRED.txt';
|
|
20
24
|
|
|
@@ -149,7 +153,9 @@ export function backupUserData(dataDir, reason = 'snapshot') {
|
|
|
149
153
|
if (copied.length > 0) {
|
|
150
154
|
markUserDataInitialized(dataDir);
|
|
151
155
|
pruneBackups();
|
|
152
|
-
process.
|
|
156
|
+
if (process.env.MIXDOG_SETUP_QUIET !== '1') {
|
|
157
|
+
process.stderr.write(`[user-data-backup] ${reason}: copied ${copied.length} file(s) to ${backupDir}\n`);
|
|
158
|
+
}
|
|
153
159
|
}
|
|
154
160
|
return { dir: copied.length > 0 ? backupDir : null, copied };
|
|
155
161
|
}
|