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.
@@ -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
- nvidia: {
27
- baseURL: 'https://integrate.api.nvidia.com/v1',
28
- defaultModel: 'meta/llama-3.3-70b-instruct',
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: JSON.parse(tc.function.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: parseJsonObject(item.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, PROVIDER_GENERATE_TOTAL_TIMEOUT_MS, `${this.name} total`);
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
- let response;
1056
+ params.stream = true;
1057
+ params.stream_options = { include_usage: true };
1058
+ let assembled;
1044
1059
  try {
1045
- response = await withRetry(
1046
- ({ signal: attemptSignal }) => this.client.chat.completions.create(params, { 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 = choice ? parseToolCalls(choice) : undefined;
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 choice?.message?.reasoning_content === 'string')
1119
- ? choice.message.reasoning_content
1149
+ const reasoningContent = (capturesReasoningContent && typeof assembled.reasoningContent === 'string')
1150
+ ? assembled.reasoningContent
1120
1151
  : null;
1121
1152
  return {
1122
- content: choice?.message?.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
- // Non-streaming transport: there are no deltas to report, so without
1169
- // an explicit stage the session sits on the loop's per-iteration
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, PROVIDER_GENERATE_TOTAL_TIMEOUT_MS, 'xai responses total');
1221
+ const totalSignal = createTimeoutSignal(signal, PROVIDER_NONSTREAM_TOTAL_TIMEOUT_MS, 'xai responses total');
1193
1222
  try {
1194
1223
  return await withRetry(
1195
- ({ signal: attemptSignal }) => this.client.responses.create(params, { 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
- response = scheduled.value;
1254
+ const streamed = scheduled.value;
1255
+ response = streamed.response;
1211
1256
  cacheLane = cacheLane || scheduled.laneMeta;
1212
- try { opts.onStreamDelta?.(); } catch { /* heartbeat best-effort */ }
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: responseOutputText(response),
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': 'mixdog',
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
- async function _loadCodexModelCache() {
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), and 'priority' is accepted
486
- // but downgraded to 'default' unless the account is entitled to
487
- // priority processing. Keep sending it so entitled accounts benefit.
488
- body.service_tier = 'priority';
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: 'mixdog',
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
- try {
1341
- const { exec } = await import('child_process');
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': 1000000,
361
- 'gpt-5.4-mini': 1000000,
362
- 'gpt-5.4-nano': 1000000,
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: guessContextWindow(modelName),
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),
@@ -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', nvidia: 'NVIDIA_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
- // Settings path is a parameter (defaulting to ~/.claude/settings.json) so it
15
- // can be redirected via MIXDOG_CLAUDE_SETTINGS_PATH for testing without a real
16
- // homedir.
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(homedir(), '.claude', 'settings.json');
22
+ || join(claudeConfigBaseDir(), 'settings.json');
20
23
  }
21
24
 
22
25
  function readJsonOrNull(filePath) {
@@ -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 / nvidia / ollama / lmstudio)
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('nvidia') || p.includes('ollama') || p.includes('lmstudio') || p.includes('groq') || p.includes('openrouter');
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
+ }
@@ -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.stderr.write(`[seed] created ${created.length} file(s): ${created.join(', ')}\n`);
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(homedir(), '.claude', 'backups', 'mixdog-user-data');
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.stderr.write(`[user-data-backup] ${reason}: copied ${copied.length} file(s) to ${backupDir}\n`);
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
  }