mixdog 0.9.19 → 0.9.21
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/LICENSE +21 -0
- package/README.md +100 -34
- package/package.json +3 -2
- package/scripts/build-tui.mjs +6 -0
- package/scripts/hook-bus-test.mjs +8 -0
- package/scripts/log-writer-guard-smoke.mjs +131 -0
- package/scripts/reactive-compact-persist-smoke.mjs +22 -6
- package/scripts/recall-bench-cases.json +0 -1
- package/scripts/recall-quality-cases.json +1 -2
- package/scripts/session-ingest-compaction-smoke.mjs +241 -0
- package/scripts/tool-smoke.mjs +150 -45
- package/src/defaults/skills/setup/SKILL.md +327 -0
- package/src/help.mjs +2 -5
- package/src/lib/mixdog-debug.cjs +13 -0
- package/src/mixdog-session-runtime.mjs +7 -3328
- package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
- package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
- package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
- package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
- package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
- package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
- package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
- package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
- package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
- package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
- package/src/runtime/channels/index.mjs +6 -2183
- package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
- package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
- package/src/runtime/channels/lib/network-retry.mjs +23 -0
- package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
- package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
- package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
- package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
- package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
- package/src/runtime/channels/lib/worker-main.mjs +777 -0
- package/src/runtime/memory/index.mjs +163 -1725
- package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
- package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
- package/src/runtime/memory/lib/http-router.mjs +811 -0
- package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
- package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
- package/src/runtime/memory/lib/memory-embed.mjs +28 -7
- package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
- package/src/runtime/memory/lib/memory.mjs +39 -0
- package/src/runtime/memory/lib/query-handlers.mjs +2 -2
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
- package/src/runtime/shared/atomic-file.mjs +138 -80
- package/src/runtime/shared/child-guardian.mjs +61 -3
- package/src/session-runtime/boot-profile.mjs +36 -0
- package/src/session-runtime/channel-config-api.mjs +70 -0
- package/src/session-runtime/context-status.mjs +181 -0
- package/src/session-runtime/env.mjs +17 -0
- package/src/session-runtime/lifecycle-api.mjs +242 -0
- package/src/session-runtime/model-route-api.mjs +198 -0
- package/src/session-runtime/provider-auth-api.mjs +135 -0
- package/src/session-runtime/resource-api.mjs +282 -0
- package/src/session-runtime/runtime-core.mjs +2104 -0
- package/src/session-runtime/session-turn-api.mjs +274 -0
- package/src/session-runtime/tool-catalog.mjs +18 -264
- package/src/session-runtime/tool-defs.mjs +2 -2
- package/src/session-runtime/workflow-agents-api.mjs +238 -0
- package/src/standalone/agent-tool.mjs +2 -2
- package/src/standalone/channel-worker.mjs +67 -7
- package/src/standalone/folder-dialog.mjs +4 -1
- package/src/standalone/memory-runtime-proxy.mjs +154 -17
- package/src/standalone/seeds.mjs +28 -1
- package/src/tui/App.jsx +40 -28
- package/src/tui/app/core-memory-picker.mjs +1 -1
- package/src/tui/app/doctor.mjs +175 -0
- package/src/tui/app/slash-commands.mjs +1 -0
- package/src/tui/app/slash-dispatch.mjs +9 -0
- package/src/tui/app/use-mouse-input.mjs +6 -0
- package/src/tui/app/use-transcript-scroll.mjs +77 -3
- package/src/tui/dist/index.mjs +2851 -2162
- package/src/tui/engine/context-state.mjs +145 -0
- package/src/tui/engine/prompt-history.mjs +27 -0
- package/src/tui/engine/session-api-ext.mjs +478 -0
- package/src/tui/engine/session-api.mjs +564 -0
- package/src/tui/engine/session-flow.mjs +485 -0
- package/src/tui/engine/turn.mjs +1078 -0
- package/src/tui/engine.mjs +68 -2620
- package/src/tui/index.jsx +7 -0
- package/vendor/ink/build/ink.js +16 -1
- package/vendor/ink/build/output.js +30 -4
- package/vendor/ink/build/render.js +5 -0
- package/src/workflows/sequential/WORKFLOW.md +0 -51
|
@@ -319,16 +319,34 @@ export function bp1HasDeferredToolManifestBlock(text) {
|
|
|
319
319
|
}
|
|
320
320
|
|
|
321
321
|
/**
|
|
322
|
-
*
|
|
322
|
+
* Skill-style manifest for tools in the deferred pool (catalog minus active
|
|
323
|
+
* wire tools). Each entry is either a bare name string or `{ name, description }`;
|
|
324
|
+
* output lines are `- name: description` (description omitted when absent),
|
|
325
|
+
* mirroring the available-skills manifest so the model calls deferred tools
|
|
326
|
+
* directly. Descriptions are compacted and stripped of `<`/`>`.
|
|
323
327
|
* Empty pool → '' (caller omits the block).
|
|
324
328
|
*/
|
|
325
|
-
export function buildDeferredToolManifest(
|
|
326
|
-
const list = [
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
329
|
+
export function buildDeferredToolManifest(entries) {
|
|
330
|
+
const list = [];
|
|
331
|
+
const seen = new Set();
|
|
332
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
333
|
+
const rawName = typeof entry === 'string' ? entry : entry?.name;
|
|
334
|
+
const name = sanitizeDeferredToolManifestName(rawName);
|
|
335
|
+
if (!name || seen.has(name)) continue;
|
|
336
|
+
seen.add(name);
|
|
337
|
+
const description = typeof entry === 'string'
|
|
338
|
+
? ''
|
|
339
|
+
: compactSkillManifestText(String(entry?.description || '').replace(/[<>]/g, ''));
|
|
340
|
+
list.push({ name, description });
|
|
341
|
+
}
|
|
330
342
|
if (!list.length) return '';
|
|
331
|
-
|
|
343
|
+
list.sort((a, b) => a.name.localeCompare(b.name));
|
|
344
|
+
return [
|
|
345
|
+
'<available-deferred-tools>',
|
|
346
|
+
'You may call any tool listed below directly by name with its arguments; it auto-loads on first call.',
|
|
347
|
+
...list.map((entry) => (entry.description ? `- ${entry.name}: ${entry.description}` : `- ${entry.name}`)),
|
|
348
|
+
'</available-deferred-tools>',
|
|
349
|
+
].join('\n');
|
|
332
350
|
}
|
|
333
351
|
|
|
334
352
|
function sanitizeMcpManifestServerName(name) {
|
|
@@ -389,12 +407,18 @@ export function stripDeferredToolManifestBlock(text) {
|
|
|
389
407
|
.trimEnd();
|
|
390
408
|
}
|
|
391
409
|
|
|
392
|
-
/** Inject
|
|
410
|
+
/** Inject the skill-style deferred pool (name + description) into BP1 once at session start; never rewrite BP1 after. */
|
|
393
411
|
export function applyInitialDeferredToolManifestToBp1(session, poolNames) {
|
|
394
412
|
if (!session || !Array.isArray(session.messages) || session.deferredToolBp1Applied) return false;
|
|
395
413
|
const pool = Array.isArray(poolNames) ? poolNames : [];
|
|
414
|
+
const descByName = new Map();
|
|
415
|
+
for (const tool of Array.isArray(session?.deferredToolCatalog) ? session.deferredToolCatalog : []) {
|
|
416
|
+
const name = String(tool?.name || '').trim();
|
|
417
|
+
if (name && !descByName.has(name)) descByName.set(name, String(tool?.description || ''));
|
|
418
|
+
}
|
|
419
|
+
const entries = pool.map((name) => ({ name, description: descByName.get(String(name).trim()) || '' }));
|
|
396
420
|
const parts = [];
|
|
397
|
-
const deferredManifest = buildDeferredToolManifest(
|
|
421
|
+
const deferredManifest = buildDeferredToolManifest(entries);
|
|
398
422
|
if (deferredManifest) parts.push(deferredManifest);
|
|
399
423
|
const mcpManifest = buildMcpInstructionsManifest(session.mcpServerInstructions, pool);
|
|
400
424
|
if (mcpManifest) parts.push(mcpManifest);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
2
|
import { loadConfig } from '../config.mjs';
|
|
3
3
|
import { sanitizeToolPairs, sanitizeAnthropicContentPairs, foldUserTextIntoToolResultTail } from '../session/context-utils.mjs';
|
|
4
|
-
import { classifyError, midstreamBackoffFor, sleepWithAbort, withRetry } from './retry-classifier.mjs';
|
|
4
|
+
import { classifyError, midstreamBackoffFor, sleepWithAbort, withRetry, retryAfterMsFromError } from './retry-classifier.mjs';
|
|
5
5
|
import { traceAgentUsage } from '../agent-trace.mjs';
|
|
6
6
|
import {
|
|
7
7
|
PROVIDER_FIRST_BYTE_TIMEOUT_MS,
|
|
@@ -518,7 +518,8 @@ export class AnthropicProvider {
|
|
|
518
518
|
try {
|
|
519
519
|
return await this._doSend(messages, model, tools, sendOpts);
|
|
520
520
|
} catch (err) {
|
|
521
|
-
if (err.message && (err.message.includes('401') || err.message.includes('403'))
|
|
521
|
+
if (err.message && (err.message.includes('401') || err.message.includes('403'))
|
|
522
|
+
&& !err.liveTextEmitted && !err.emittedToolCall && !err.unsafeToRetry) {
|
|
522
523
|
process.stderr.write(`[provider] Auth error, re-reading config...\n`);
|
|
523
524
|
this.reloadApiKey();
|
|
524
525
|
return await this._doSend(messages, model, tools, sendOpts);
|
|
@@ -745,6 +746,22 @@ export class AnthropicProvider {
|
|
|
745
746
|
const err = new Error(`Anthropic API ${res.status}: ${text.slice(0, 200)}`);
|
|
746
747
|
err.status = res.status;
|
|
747
748
|
err.httpStatus = res.status;
|
|
749
|
+
// Carry response headers so withRetry can honor a
|
|
750
|
+
// short Retry-After and upstream can read quota hints.
|
|
751
|
+
err.headers = res.headers;
|
|
752
|
+
err.response = { status: res.status, headers: res.headers };
|
|
753
|
+
// 429: promote to a quota-style error equivalent to
|
|
754
|
+
// anthropic-oauth's anthropicQuotaError so upstream
|
|
755
|
+
// quota handling and unsafe-to-retry gating apply.
|
|
756
|
+
if (res.status === 429) {
|
|
757
|
+
const retryAfterMs = retryAfterMsFromError({ headers: res.headers, response: { headers: res.headers } });
|
|
758
|
+
err.name = 'ProviderQuotaError';
|
|
759
|
+
err.code = 'PROVIDER_QUOTA';
|
|
760
|
+
err.retryAfterMs = retryAfterMs;
|
|
761
|
+
err.providerQuota = true;
|
|
762
|
+
err.quotaExceeded = true;
|
|
763
|
+
err.unsafeToRetry = true;
|
|
764
|
+
}
|
|
748
765
|
throw err;
|
|
749
766
|
}
|
|
750
767
|
if (!res.body) {
|
|
@@ -858,6 +875,21 @@ export class AnthropicProvider {
|
|
|
858
875
|
await _midstreamSleepWithAbort(midstreamBackoffFor(attemptIndex + 1), totalSignal);
|
|
859
876
|
continue;
|
|
860
877
|
}
|
|
878
|
+
if (classifyError(err) === 'transient'
|
|
879
|
+
&& !midState.sawMessageStart
|
|
880
|
+
&& !midState.emittedToolCall
|
|
881
|
+
&& attemptIndex < MAX_MIDSTREAM_RETRIES) {
|
|
882
|
+
firstAttemptError = err;
|
|
883
|
+
firstAttemptClassifier = err?.providerErrorType || 'sse_transient';
|
|
884
|
+
try { streamController.abort?.(err); } catch {}
|
|
885
|
+
try {
|
|
886
|
+
process.stderr.write(
|
|
887
|
+
`[${this.name}] transient SSE error — retry ${attemptIndex + 1}/${MAX_MIDSTREAM_RETRIES} (${err?.providerErrorType || err?.message || 'unknown'})\n`,
|
|
888
|
+
);
|
|
889
|
+
} catch {}
|
|
890
|
+
await _midstreamSleepWithAbort(midstreamBackoffFor(attemptIndex + 1), totalSignal);
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
861
893
|
if ((err?.truncatedStream === true || err?.code === 'TRUNCATED_STREAM')
|
|
862
894
|
&& classifyError(err) === 'transient'
|
|
863
895
|
&& !midState.emittedToolCall
|
|
@@ -478,6 +478,9 @@ export class GeminiProvider {
|
|
|
478
478
|
return await this._doSend(messages, model, tools, sendOpts);
|
|
479
479
|
} catch (err) {
|
|
480
480
|
if (err.message && (err.message.includes('401') || err.message.includes('403'))) {
|
|
481
|
+
if (err.liveTextEmitted === true || err.emittedToolCall === true || err.unsafeToRetry === true) {
|
|
482
|
+
throw err;
|
|
483
|
+
}
|
|
481
484
|
process.stderr.write(`[provider] Auth error, re-reading config...\n`);
|
|
482
485
|
this.reloadApiKey();
|
|
483
486
|
return await this._doSend(messages, model, tools, sendOpts);
|
|
@@ -308,10 +308,7 @@ export function forgetGrokOAuthCredentials() {
|
|
|
308
308
|
}
|
|
309
309
|
|
|
310
310
|
let _refreshInFlight = null;
|
|
311
|
-
async function
|
|
312
|
-
if (!tokens?.refresh_token) {
|
|
313
|
-
throw new Error('[grok-oauth] refresh token not available — open /providers in mixdog to sign in again');
|
|
314
|
-
}
|
|
311
|
+
async function _postRefresh(tokens) {
|
|
315
312
|
const tokenEndpoint = tokens.token_endpoint
|
|
316
313
|
? assertTrustedXaiEndpoint(tokens.token_endpoint, 'token endpoint')
|
|
317
314
|
: (await fetchDiscovery()).token_endpoint;
|
|
@@ -365,6 +362,34 @@ async function refreshTokens(tokens) {
|
|
|
365
362
|
}
|
|
366
363
|
}
|
|
367
364
|
|
|
365
|
+
// 16 mixdog processes share one grok-oauth.json and xAI rotates refresh
|
|
366
|
+
// tokens single-use. Re-read the store immediately before the network POST so
|
|
367
|
+
// a peer's just-completed rotation is adopted instead of spending our
|
|
368
|
+
// (now-stale) refresh_token; on invalid_grant, re-read once and retry with a
|
|
369
|
+
// peer-rotated refresh_token before propagating.
|
|
370
|
+
async function refreshTokens(tokens, { force = false } = {}) {
|
|
371
|
+
if (!tokens?.refresh_token) {
|
|
372
|
+
throw new Error('[grok-oauth] refresh token not available — open /providers in mixdog to sign in again');
|
|
373
|
+
}
|
|
374
|
+
const disk = _loadOwnTokens();
|
|
375
|
+
const validAfter = Date.now() + (force ? 0 : TOKEN_REFRESH_SKEW_MS);
|
|
376
|
+
if (disk?.access_token && disk.access_token !== tokens.access_token
|
|
377
|
+
&& (!disk.expires_at || disk.expires_at >= validAfter)) {
|
|
378
|
+
return disk;
|
|
379
|
+
}
|
|
380
|
+
try {
|
|
381
|
+
return await _postRefresh(tokens);
|
|
382
|
+
} catch (err) {
|
|
383
|
+
if (err?.isInvalidGrant) {
|
|
384
|
+
const rotated = _loadOwnTokens();
|
|
385
|
+
if (rotated?.refresh_token && rotated.refresh_token !== tokens.refresh_token) {
|
|
386
|
+
return await _postRefresh(rotated);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
throw err;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
368
393
|
// --- Model catalog cache (24h disk TTL) ---
|
|
369
394
|
const _modelCache = makeModelCache({
|
|
370
395
|
fileName: 'grok-oauth-models.json',
|
|
@@ -530,6 +555,9 @@ export class GrokOAuthProvider {
|
|
|
530
555
|
tokens = null;
|
|
531
556
|
_inner = null;
|
|
532
557
|
_innerKey = null;
|
|
558
|
+
// Grace window after a non-force refresh failure: keep serving the current
|
|
559
|
+
// still-valid access_token instead of thrashing the shared refresh_token.
|
|
560
|
+
_refreshFallbackUntil = 0;
|
|
533
561
|
|
|
534
562
|
constructor(config) {
|
|
535
563
|
this.config = config || {};
|
|
@@ -553,15 +581,44 @@ export class GrokOAuthProvider {
|
|
|
553
581
|
if (disk?.access_token) this.tokens = disk;
|
|
554
582
|
this._lastDiskScan = ownM;
|
|
555
583
|
}
|
|
584
|
+
if (!forceRefresh && this._refreshFallbackUntil > Date.now() && this.tokens?.access_token
|
|
585
|
+
&& (!this.tokens.expires_at || this.tokens.expires_at > Date.now())) {
|
|
586
|
+
return this.tokens;
|
|
587
|
+
}
|
|
556
588
|
const expiring = this.tokens.expires_at
|
|
557
589
|
&& this.tokens.expires_at < Date.now() + TOKEN_REFRESH_SKEW_MS;
|
|
558
590
|
if (forceRefresh || expiring) {
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
.
|
|
564
|
-
|
|
591
|
+
const currentToken = this.tokens?.access_token || null;
|
|
592
|
+
try {
|
|
593
|
+
if (_refreshInFlight) {
|
|
594
|
+
const shared = await _refreshInFlight;
|
|
595
|
+
this.tokens = shared;
|
|
596
|
+
// A forced caller must not accept a shared non-force result
|
|
597
|
+
// that merely handed back its own prior token: start a fresh
|
|
598
|
+
// forced refresh instead (mirror openai-oauth).
|
|
599
|
+
if (forceRefresh && shared?.access_token === currentToken) {
|
|
600
|
+
if (!_refreshInFlight) {
|
|
601
|
+
_refreshInFlight = refreshTokens(this.tokens, { force: true })
|
|
602
|
+
.finally(() => { _refreshInFlight = null; });
|
|
603
|
+
}
|
|
604
|
+
this.tokens = await _refreshInFlight;
|
|
605
|
+
}
|
|
606
|
+
} else {
|
|
607
|
+
_refreshInFlight = refreshTokens(this.tokens, { force: forceRefresh })
|
|
608
|
+
.finally(() => { _refreshInFlight = null; });
|
|
609
|
+
this.tokens = await _refreshInFlight;
|
|
610
|
+
}
|
|
611
|
+
this._refreshFallbackUntil = 0;
|
|
612
|
+
} catch (err) {
|
|
613
|
+
// Non-force failure while the current token is still valid: serve
|
|
614
|
+
// it under a grace window rather than throwing (mirror openai-oauth).
|
|
615
|
+
if (!forceRefresh && currentToken
|
|
616
|
+
&& (!this.tokens?.expires_at || this.tokens.expires_at > Date.now())) {
|
|
617
|
+
this._refreshFallbackUntil = Date.now() + TOKEN_REFRESH_SKEW_MS;
|
|
618
|
+
process.stderr.write(`[grok-oauth] Refresh failed (${String(err?.message || err).slice(0, 120)}); using still-valid current token\n`);
|
|
619
|
+
return this.tokens;
|
|
620
|
+
}
|
|
621
|
+
throw err;
|
|
565
622
|
}
|
|
566
623
|
}
|
|
567
624
|
return this.tokens;
|
|
@@ -173,6 +173,9 @@ export class OpenAICompatProvider {
|
|
|
173
173
|
return await this._doSend(messages, model, tools, sendOpts);
|
|
174
174
|
} catch (err) {
|
|
175
175
|
if (err.message && (err.message.includes('401') || err.message.includes('403'))) {
|
|
176
|
+
if (err.liveTextEmitted === true || err.emittedToolCall === true || err.unsafeToRetry === true) {
|
|
177
|
+
throw err;
|
|
178
|
+
}
|
|
176
179
|
process.stderr.write(`[provider] Auth error, re-reading config...\n`);
|
|
177
180
|
this.reloadApiKey();
|
|
178
181
|
return await this._doSend(messages, model, tools, sendOpts);
|
|
@@ -27,6 +27,13 @@ import { createLeakGuard, createToolCallDedupe, dedupeToolCallList } from './ant
|
|
|
27
27
|
import { customToolCallFromResponseItem } from './custom-tool-wire.mjs';
|
|
28
28
|
import { CODEX_OAUTH_ORIGINATOR, CODEX_RESPONSES_URL, _displayCodexModel } from './openai-oauth.mjs';
|
|
29
29
|
|
|
30
|
+
// Public OpenAI Responses API endpoint for the api-key `openai` provider.
|
|
31
|
+
// The openai-direct WS transport hits the same origin (openai-ws-pool
|
|
32
|
+
// OPENAI_WS_URL = wss://api.openai.com/v1/responses); this HTTP/SSE fallback
|
|
33
|
+
// mirrors it so OpenAIDirectProvider can fall back off WebSocket like
|
|
34
|
+
// openai-oauth. Same Responses SSE wire format, only endpoint + auth differ.
|
|
35
|
+
const OPENAI_DIRECT_RESPONSES_URL = 'https://api.openai.com/v1/responses';
|
|
36
|
+
|
|
30
37
|
export function _envFlag(name, fallback = true) {
|
|
31
38
|
const raw = process.env[name];
|
|
32
39
|
if (raw == null || raw === '') return fallback;
|
|
@@ -116,6 +123,18 @@ function _pushOutputTextAnnotations(part, citations, citationKeys) {
|
|
|
116
123
|
}
|
|
117
124
|
|
|
118
125
|
function _buildOpenAIHttpFallbackHeaders({ auth, cacheKey }) {
|
|
126
|
+
if (auth?.type === 'openai-direct') {
|
|
127
|
+
// Public API-key auth: Bearer <OPENAI_API_KEY>, no chatgpt-account-id /
|
|
128
|
+
// originator (mirrors openai-ws-pool _buildHandshakeHeaders' direct
|
|
129
|
+
// branch). session_id anchors are an OAuth-backend behavior, so omit
|
|
130
|
+
// them — the public API keys its prefix cache off body.prompt_cache_key.
|
|
131
|
+
return {
|
|
132
|
+
Authorization: `Bearer ${auth.apiKey}`,
|
|
133
|
+
'Content-Type': 'application/json',
|
|
134
|
+
Accept: 'text/event-stream',
|
|
135
|
+
'x-client-request-id': randomBytes(16).toString('hex'),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
119
138
|
const headers = {
|
|
120
139
|
Authorization: `Bearer ${auth.access_token}`,
|
|
121
140
|
'Content-Type': 'application/json',
|
|
@@ -189,10 +208,13 @@ export async function sendViaHttpSse({
|
|
|
189
208
|
);
|
|
190
209
|
const headers = _buildOpenAIHttpFallbackHeaders({ auth, cacheKey });
|
|
191
210
|
const fetchStartedAt = Date.now();
|
|
211
|
+
const responsesUrl = auth?.type === 'openai-direct'
|
|
212
|
+
? OPENAI_DIRECT_RESPONSES_URL
|
|
213
|
+
: CODEX_RESPONSES_URL;
|
|
192
214
|
let response;
|
|
193
215
|
try {
|
|
194
216
|
try { onStageChange?.('requesting'); } catch {}
|
|
195
|
-
response = await fetchFn(
|
|
217
|
+
response = await fetchFn(responsesUrl, {
|
|
196
218
|
method: 'POST',
|
|
197
219
|
headers,
|
|
198
220
|
body: JSON.stringify(body),
|
|
@@ -304,6 +326,17 @@ export async function sendViaHttpSse({
|
|
|
304
326
|
// a second attempt.
|
|
305
327
|
let emittedText = false;
|
|
306
328
|
|
|
329
|
+
// Tool-emit invariant (mirrors emittedText, WS path's emittedToolCall): set
|
|
330
|
+
// once onToolCall has actually dispatched a call. A failure afterwards is
|
|
331
|
+
// non-retryable — the side-effecting tool already ran, and any upstream
|
|
332
|
+
// retry/fallback would double-execute it. Stamped onto errors below so
|
|
333
|
+
// shouldFallbackTransport / the WS auth-retry gate refuse to reissue.
|
|
334
|
+
let emittedToolCall = false;
|
|
335
|
+
const _stampToolSafety = (err) => {
|
|
336
|
+
if (emittedToolCall && err) { try { err.emittedToolCall = true; err.unsafeToRetry = true; } catch {} }
|
|
337
|
+
return err;
|
|
338
|
+
};
|
|
339
|
+
|
|
307
340
|
// Single-emit guard for tool calls (matches the WS path's
|
|
308
341
|
// emittedToolCall intent). The HTTP/SSE event stream can surface the
|
|
309
342
|
// same function_call across multiple frames — response.function_call_arguments.done,
|
|
@@ -324,6 +357,7 @@ export async function sendViaHttpSse({
|
|
|
324
357
|
if (emittedToolCallIds.has(call.id)) return;
|
|
325
358
|
emittedToolCallIds.add(call.id);
|
|
326
359
|
if (!_toolDedupe.shouldDispatch(call.name, call.arguments)) return;
|
|
360
|
+
emittedToolCall = true;
|
|
327
361
|
try { onToolCall?.(call); } catch {}
|
|
328
362
|
};
|
|
329
363
|
|
|
@@ -676,6 +710,9 @@ export async function sendViaHttpSse({
|
|
|
676
710
|
// Live-text invariant: once a non-empty chunk has been relayed it
|
|
677
711
|
// cannot be withdrawn — flag the error so no upstream layer retries.
|
|
678
712
|
if (emittedText && err) { try { err.liveTextEmitted = true; err.unsafeToRetry = true; } catch {} }
|
|
713
|
+
// Tool-emit invariant: an error after a dispatched tool call must not
|
|
714
|
+
// reissue the turn (double-execution). Stamp emittedToolCall too.
|
|
715
|
+
_stampToolSafety(err);
|
|
679
716
|
throw err;
|
|
680
717
|
} finally {
|
|
681
718
|
_clearSemanticIdle();
|
|
@@ -688,10 +725,10 @@ export async function sendViaHttpSse({
|
|
|
688
725
|
|
|
689
726
|
const unresolved = toolCalls.find(t => t._pendingItemId);
|
|
690
727
|
if (unresolved) {
|
|
691
|
-
throw new Error(`OpenAI OAuth HTTP fallback function_call salvage failed: missing call_id/name for item_id=${unresolved._pendingItemId || '?'}`);
|
|
728
|
+
throw _stampToolSafety(new Error(`OpenAI OAuth HTTP fallback function_call salvage failed: missing call_id/name for item_id=${unresolved._pendingItemId || '?'}`));
|
|
692
729
|
}
|
|
693
730
|
if (!completed && !content && !toolCalls.length) {
|
|
694
|
-
throw new Error('OpenAI OAuth HTTP fallback ended before response.completed');
|
|
731
|
+
throw _stampToolSafety(new Error('OpenAI OAuth HTTP fallback ended before response.completed'));
|
|
695
732
|
}
|
|
696
733
|
|
|
697
734
|
const liveModel = model || useModel;
|
|
@@ -16,6 +16,9 @@ import { sendViaWebSocket } from './openai-oauth-ws.mjs';
|
|
|
16
16
|
import { buildRequestBody } from './openai-oauth.mjs';
|
|
17
17
|
import { enrichModels } from './model-catalog.mjs';
|
|
18
18
|
import { sanitizeModelList } from './model-list-sanitize.mjs';
|
|
19
|
+
import { sendViaHttpSse, _envFlag } from './openai-oauth-http-sse.mjs';
|
|
20
|
+
import { shouldFallbackTransport } from './retry-classifier.mjs';
|
|
21
|
+
import { loadConfig } from '../config.mjs';
|
|
19
22
|
import {
|
|
20
23
|
resolveProviderCacheKey,
|
|
21
24
|
resolveProviderPromptCacheLane,
|
|
@@ -52,6 +55,23 @@ export class OpenAIDirectProvider {
|
|
|
52
55
|
if (!k) throw new Error('OPENAI_API_KEY not configured (providers.openai.apiKey)');
|
|
53
56
|
return k;
|
|
54
57
|
}
|
|
58
|
+
// Auth-recovery mirror of openai-compat.reloadApiKey: on a 401/403 the key
|
|
59
|
+
// was likely rotated in config after this provider instance was built, so
|
|
60
|
+
// re-read providers.openai.apiKey from disk before the single retry.
|
|
61
|
+
// Returns the fresh key (or null if none) — no client to rebuild here since
|
|
62
|
+
// the WS/HTTP transports take the key per-call via the `auth` object.
|
|
63
|
+
reloadApiKey() {
|
|
64
|
+
try {
|
|
65
|
+
const freshConfig = loadConfig();
|
|
66
|
+
const cfg = freshConfig.providers?.openai;
|
|
67
|
+
const newKey = cfg?.apiKey || this.config.apiKey;
|
|
68
|
+
if (newKey) {
|
|
69
|
+
this.config = { ...(this.config || {}), ...(cfg || {}), apiKey: newKey };
|
|
70
|
+
return newKey;
|
|
71
|
+
}
|
|
72
|
+
} catch { /* best effort */ }
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
55
75
|
async send(messages, model, tools, sendOpts) {
|
|
56
76
|
const opts = sendOpts || {};
|
|
57
77
|
const onStageChange = typeof opts.onStageChange === 'function' ? opts.onStageChange : null;
|
|
@@ -109,10 +129,8 @@ export class OpenAIDirectProvider {
|
|
|
109
129
|
const cacheKey = body.prompt_cache_key || resolveProviderCacheKey(opts, 'openai');
|
|
110
130
|
const iteration = Number.isFinite(Number(opts.iteration)) ? Number(opts.iteration) : null;
|
|
111
131
|
const auth = { type: 'openai-direct', apiKey };
|
|
112
|
-
|
|
113
|
-
auth,
|
|
132
|
+
const common = {
|
|
114
133
|
body,
|
|
115
|
-
sendOpts: opts,
|
|
116
134
|
onStreamDelta,
|
|
117
135
|
onToolCall,
|
|
118
136
|
onTextDelta,
|
|
@@ -122,8 +140,60 @@ export class OpenAIDirectProvider {
|
|
|
122
140
|
cacheKey,
|
|
123
141
|
iteration,
|
|
124
142
|
useModel,
|
|
143
|
+
};
|
|
144
|
+
const dispatchWs = (a) => sendViaWebSocket({
|
|
145
|
+
...common,
|
|
146
|
+
auth: a,
|
|
147
|
+
sendOpts: opts,
|
|
125
148
|
displayModel: (id) => id,
|
|
126
149
|
});
|
|
150
|
+
// WS→HTTP/SSE fallback mirrors the openai-oauth wrapper: the shared
|
|
151
|
+
// HTTP transport now accepts auth.type==='openai-direct' (public
|
|
152
|
+
// Responses endpoint + Bearer <apiKey>), so the api-key provider gets
|
|
153
|
+
// the same envelope. Gate via shouldFallbackTransport (denies
|
|
154
|
+
// 401/403/404/429 + liveTextEmitted/emittedToolCall/unsafeToRetry).
|
|
155
|
+
const httpFallbackEnabled = _envFlag('MIXDOG_OPENAI_HTTP_FALLBACK', true);
|
|
156
|
+
const dispatchHttp = (a) => {
|
|
157
|
+
if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) {
|
|
158
|
+
process.stderr.write('[openai-ws] WebSocket unhealthy; falling back to HTTP/SSE\n');
|
|
159
|
+
}
|
|
160
|
+
return sendViaHttpSse({ ...common, auth: a, opts, fetchFn: opts._fetchFn });
|
|
161
|
+
};
|
|
162
|
+
try {
|
|
163
|
+
return await dispatchWs(auth);
|
|
164
|
+
} catch (err) {
|
|
165
|
+
const status = err?.httpStatus;
|
|
166
|
+
// Live-text/tool invariant: never reissue a turn that already
|
|
167
|
+
// relayed visible output or dispatched a tool call.
|
|
168
|
+
const unsafeToRetry = err?.liveTextEmitted === true
|
|
169
|
+
|| err?.emittedToolCall === true
|
|
170
|
+
|| err?.unsafeToRetry === true;
|
|
171
|
+
// (1) 401/403 → reload apiKey from config and retry once over WS.
|
|
172
|
+
// shouldFallbackTransport denies 401/403, so this branch owns
|
|
173
|
+
// its own guard.
|
|
174
|
+
if ((status === 401 || status === 403) && !unsafeToRetry) {
|
|
175
|
+
process.stderr.write(`[openai-ws] ${status} — reloading apiKey and retrying once\n`);
|
|
176
|
+
const freshKey = this.reloadApiKey();
|
|
177
|
+
if (freshKey) {
|
|
178
|
+
const retryAuth = { type: 'openai-direct', apiKey: freshKey };
|
|
179
|
+
try {
|
|
180
|
+
return await dispatchWs(retryAuth);
|
|
181
|
+
} catch (retryErr) {
|
|
182
|
+
if (shouldFallbackTransport(retryErr, { signal: externalSignal, enabled: httpFallbackEnabled })) {
|
|
183
|
+
return await dispatchHttp(retryAuth);
|
|
184
|
+
}
|
|
185
|
+
throw retryErr;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
throw err;
|
|
189
|
+
}
|
|
190
|
+
// (2) WS transport failure → HTTP/SSE fallback (predicate handles
|
|
191
|
+
// the safety denies).
|
|
192
|
+
if (shouldFallbackTransport(err, { signal: externalSignal, enabled: httpFallbackEnabled })) {
|
|
193
|
+
return await dispatchHttp(auth);
|
|
194
|
+
}
|
|
195
|
+
throw err;
|
|
196
|
+
}
|
|
127
197
|
}
|
|
128
198
|
async listModels() {
|
|
129
199
|
try {
|