@visual-z/xeno 0.3.0 → 0.5.0
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 +19 -0
- package/README.md +55 -16
- package/docs/app-server.md +86 -0
- package/docs/install.md +1 -1
- package/docs/providers.md +20 -6
- package/package.json +3 -2
- package/src/apps/app-server.ts +28 -0
- package/src/apps/cli.ts +25 -10
- package/src/apps/commands.ts +10 -6
- package/src/apps/completions.ts +15 -13
- package/src/apps/control-transport.ts +89 -0
- package/src/apps/model-catalog.ts +2 -1
- package/src/apps/preferences.ts +20 -0
- package/src/apps/provider-connect.ts +4 -4
- package/src/apps/shell.ts +73 -50
- package/src/apps/startup-input.ts +7 -2
- package/src/apps/startup.ts +52 -19
- package/src/apps/ui/app.ts +210 -56
- package/src/apps/ui/control-model.ts +26 -0
- package/src/apps/ui/surface.ts +2 -0
- package/src/core/completion-action.ts +20 -0
- package/src/core/completion-review.ts +38 -29
- package/src/core/control-protocol.ts +62 -0
- package/src/core/control-view.ts +42 -0
- package/src/core/event-validation.ts +1 -1
- package/src/core/events.ts +2 -0
- package/src/core/harness-diagnostics.ts +206 -0
- package/src/core/model-preferences.ts +28 -0
- package/src/core/oauth-fields.ts +44 -0
- package/src/core/permissions.ts +13 -7
- package/src/core/program-status.ts +31 -0
- package/src/core/project.ts +25 -1
- package/src/core/request-progress.ts +87 -0
- package/src/core/request-state.ts +114 -0
- package/src/core/settings.ts +8 -1
- package/src/core/tool-input.ts +6 -0
- package/src/runtime/auth.ts +10 -3
- package/src/runtime/catalog.ts +21 -4
- package/src/runtime/control-connection.ts +104 -0
- package/src/runtime/harness-eval.ts +3 -1
- package/src/runtime/loop.ts +72 -19
- package/src/runtime/model.ts +9 -0
- package/src/runtime/native-eval.ts +6 -3
- package/src/runtime/oauth.ts +80 -3
- package/src/runtime/pi-provider.ts +169 -12
- package/src/runtime/program-status.ts +38 -0
- package/src/runtime/provider-connect.ts +2 -2
- package/src/runtime/provider-presets.ts +1 -1
- package/src/runtime/provider-request.ts +13 -2
- package/src/runtime/provider.ts +23 -9
- package/src/runtime/session-control.ts +198 -0
- package/src/runtime/tools.ts +11 -9
- package/src/store/resources.ts +13 -22
- package/src/store/user-settings.ts +30 -0
- package/src/tui/ui/composer.ts +3 -2
- package/src/tui/ui/control-panel.ts +25 -0
- package/src/tui/ui/control-sidebar.ts +49 -0
- package/src/tui/ui/mascot.ts +1 -1
- package/src/tui/ui/screen.ts +107 -11
- package/src/tui/ui/status.ts +8 -0
package/src/runtime/loop.ts
CHANGED
|
@@ -25,7 +25,7 @@ import type { Handoff } from "../core/handoff.ts";
|
|
|
25
25
|
import { boundHandoff, DEFAULT_HANDOFF_TOKENS } from "../core/history.ts";
|
|
26
26
|
import { boundaryPreparation, continuationGuidance } from "../core/continuation.ts";
|
|
27
27
|
import type { FrozenPrefix, Message, ProjectInput } from "../core/project.ts";
|
|
28
|
-
import { projectContext, PROJECTION_VERSION } from "../core/project.ts";
|
|
28
|
+
import { projectContext, PROJECTION_VERSION, toolCallIdentityViolations } from "../core/project.ts";
|
|
29
29
|
import { deriveHandoff, carryForward } from "../core/derive.ts";
|
|
30
30
|
import { latestObservedFileVersion } from "../core/file-precondition.ts";
|
|
31
31
|
import { latestProcessReceipt } from "../core/process-receipt.ts";
|
|
@@ -59,7 +59,8 @@ import { branchStatus, processStatus, deriveProcesses, type ProcessObservation }
|
|
|
59
59
|
import { schedulingWindow } from "../core/scheduling.ts";
|
|
60
60
|
import { estimateTokens, anchoredCount, scaledCounter, type TokenCounter, type Anchor } from "../core/tokens.ts";
|
|
61
61
|
import { isContextOverflow } from "../core/overflow.ts";
|
|
62
|
-
import {
|
|
62
|
+
import { requestCompletion } from "../core/completion-review.ts";
|
|
63
|
+
import { deriveRequestState, type RequestState } from "../core/request-state.ts";
|
|
63
64
|
import { churnReview } from "../core/churn.ts";
|
|
64
65
|
import { deriveBranchReceipt } from "../core/branch-receipt.ts";
|
|
65
66
|
import { skillTools, type Skill } from "../core/skills.ts";
|
|
@@ -78,7 +79,7 @@ import { recoverCut } from "../store/recovery.ts";
|
|
|
78
79
|
import { runHistoryTool } from "./history-tools.ts";
|
|
79
80
|
import { prepareWorkingNote } from "./working-notes.ts";
|
|
80
81
|
import { copyAncestryTo } from "../store/sessions.ts";
|
|
81
|
-
import type
|
|
82
|
+
import { FailedResponseError, type Provider, type Turn, type StreamDelta } from "./provider.ts";
|
|
82
83
|
import {
|
|
83
84
|
scanWorkspace, diffScans, reconcile, DEFAULT_IGNORE, workspaceVersion,
|
|
84
85
|
type FileSet, type RestoreResult,
|
|
@@ -150,6 +151,8 @@ export interface LoopEvent {
|
|
|
150
151
|
/** branch_start: wall-clock milliseconds, for asserting the stagger. */
|
|
151
152
|
at?: number;
|
|
152
153
|
outcome?: ExecutionOutcome;
|
|
154
|
+
/** Q23: task evidence is separate from the execution outcome; rebuilt from original receipts. */
|
|
155
|
+
request?: RequestState;
|
|
153
156
|
progress?: Extract<StreamDelta, { kind: "tool" | "retry" }> | { kind: "output"; text: string; name: string };
|
|
154
157
|
}
|
|
155
158
|
|
|
@@ -331,6 +334,7 @@ type DeferredToolResult = ToolResult & { accept?: () => Promise<LoopEvent[]>; an
|
|
|
331
334
|
type ToolCall = Extract<AssistantBlock, { type: "tool_use" }>;
|
|
332
335
|
type SessionBrokerReceipt = BrokerReceipt<DeferredToolResult> & { events: LoopEvent[] };
|
|
333
336
|
type PreparedBrokerCall = { run(emit?: (event: LoopEvent) => void): Promise<SessionBrokerReceipt> };
|
|
337
|
+
export type CommittedItem = { node: number; id: string; type: LogEvent["t"] };
|
|
334
338
|
|
|
335
339
|
export class Session {
|
|
336
340
|
private readonly execOwner = randomUUID();
|
|
@@ -349,6 +353,7 @@ export class Session {
|
|
|
349
353
|
private pendingAdoption: number | null = null;
|
|
350
354
|
private readonly allocatedEventIds = new Set<string>();
|
|
351
355
|
private recordTail: Promise<void> = Promise.resolve();
|
|
356
|
+
private readonly recordListeners = new Set<(item: CommittedItem) => void>();
|
|
352
357
|
readonly config: SessionConfig;
|
|
353
358
|
/**
|
|
354
359
|
* Replaced by switchModel. Not readonly: a live session can change which
|
|
@@ -426,9 +431,17 @@ export class Session {
|
|
|
426
431
|
}
|
|
427
432
|
|
|
428
433
|
private async observeEvidence(): Promise<void> {
|
|
429
|
-
|
|
430
|
-
|
|
434
|
+
let proof = this.state.events.findLast(event => event.t === "tool_result" && event.effect?.kind === "command");
|
|
435
|
+
let prior = proof?.t === "tool_result" && proof.effect?.kind === "command" ? proof.effect.workspace_sha256 :
|
|
431
436
|
this.state.handoff?.state.completed.at(-1)?.evidence.workspace_sha256;
|
|
437
|
+
// A short task need not create a completed note. Its last command still
|
|
438
|
+
// establishes the comparison version when the current segment is empty.
|
|
439
|
+
if (!proof && this.config.tools.workspace) {
|
|
440
|
+
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
441
|
+
if (!history.ok) throw new Error(history.error);
|
|
442
|
+
proof = history.nodes.flatMap(part => part.events).findLast(event => event.t === "tool_result" && event.effect?.kind === "command");
|
|
443
|
+
if (proof?.t === "tool_result" && proof.effect?.kind === "command") prior = proof.effect.workspace_sha256;
|
|
444
|
+
}
|
|
432
445
|
if (proof || prior || this.config.acceptanceContract) await this.config.tools.workspace?.observe(prior);
|
|
433
446
|
}
|
|
434
447
|
|
|
@@ -867,6 +880,11 @@ export class Session {
|
|
|
867
880
|
});
|
|
868
881
|
}
|
|
869
882
|
|
|
883
|
+
/** Q24: publish identities only after durable append, including non-model user actions. */
|
|
884
|
+
subscribeRecords(listener: (item: CommittedItem) => void): () => void {
|
|
885
|
+
this.recordListeners.add(listener); return () => { this.recordListeners.delete(listener); };
|
|
886
|
+
}
|
|
887
|
+
|
|
870
888
|
private async record(event: LogEvent): Promise<void> {
|
|
871
889
|
// D8: runtime callers may retain mutable results. The in-memory projection
|
|
872
890
|
// must own the same JSON value that was appended, independent of later edits.
|
|
@@ -876,6 +894,9 @@ export class Session {
|
|
|
876
894
|
this.state.events.push(event);
|
|
877
895
|
if (this.stall !== null) this.stall = advanceStall(this.stall, [{ node: this.state.node, event }]);
|
|
878
896
|
if (this.recovery !== null) this.recovery = advanceRecovery(this.recovery, [event], path => resolve(this.config.tools.root, path));
|
|
897
|
+
for (const listener of this.recordListeners) {
|
|
898
|
+
try { listener({ node: this.state.node, id: event.id, type: event.t }); } catch { /* observers never own the append */ }
|
|
899
|
+
}
|
|
879
900
|
});
|
|
880
901
|
this.recordTail = saved.catch(() => {});
|
|
881
902
|
await saved;
|
|
@@ -963,6 +984,14 @@ export class Session {
|
|
|
963
984
|
return (await this.currentHandoff()).acceptance;
|
|
964
985
|
}
|
|
965
986
|
|
|
987
|
+
async requestState(): Promise<RequestState | undefined> {
|
|
988
|
+
// Use the same idle workspace/target observation boundary as /acceptance.
|
|
989
|
+
if (!this.running) await this.acceptanceState();
|
|
990
|
+
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
991
|
+
if (!history.ok) throw new Error(history.error);
|
|
992
|
+
return deriveRequestState(history.nodes);
|
|
993
|
+
}
|
|
994
|
+
|
|
966
995
|
/** Only called at paired, owned boundaries; probes never enter a pending model tool exchange. */
|
|
967
996
|
private async refreshRuntimeTargets(): Promise<void> {
|
|
968
997
|
const state = (await this.currentHandoff()).acceptance;
|
|
@@ -1103,6 +1132,7 @@ export class Session {
|
|
|
1103
1132
|
userText: UserInput,
|
|
1104
1133
|
signal?: AbortSignal,
|
|
1105
1134
|
pull?: (phase: Phase) => Promise<UserInput | null>,
|
|
1135
|
+
requestId?: string,
|
|
1106
1136
|
): AsyncGenerator<LoopEvent> {
|
|
1107
1137
|
if (this.cleanup || this.closing) throw new Error("session is draining or disposed");
|
|
1108
1138
|
const owned = this.execution.begin(signal);
|
|
@@ -1110,7 +1140,7 @@ export class Session {
|
|
|
1110
1140
|
this.turnTools = savedTools;
|
|
1111
1141
|
this.config.tools = { ...savedTools };
|
|
1112
1142
|
this.installSkillResources();
|
|
1113
|
-
const events = this.run(userText, owned.signal, pull);
|
|
1143
|
+
const events = this.run(userText, owned.signal, pull, requestId);
|
|
1114
1144
|
let terminal = false;
|
|
1115
1145
|
let exhausted = false;
|
|
1116
1146
|
try {
|
|
@@ -1123,13 +1153,16 @@ export class Session {
|
|
|
1123
1153
|
t: "outcome", id: this.clock.nextId(), at: this.clock.now(),
|
|
1124
1154
|
reason: item.value.reason ?? item.value.outcome.reason, outcome: item.value.outcome,
|
|
1125
1155
|
});
|
|
1156
|
+
const request = await this.requestState();
|
|
1157
|
+
if (request) item.value.request = request;
|
|
1126
1158
|
}
|
|
1127
1159
|
yield item.value;
|
|
1128
1160
|
}
|
|
1129
1161
|
if (!terminal) {
|
|
1130
1162
|
const outcome = executionOutcome("snapshot_failed");
|
|
1131
1163
|
await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(), reason: "workspace snapshot failed", outcome });
|
|
1132
|
-
|
|
1164
|
+
const request = await this.requestState();
|
|
1165
|
+
yield { kind: "done", reason: "stopped: workspace snapshot failed", outcome, ...(request ? { request } : {}) };
|
|
1133
1166
|
}
|
|
1134
1167
|
} catch (error) {
|
|
1135
1168
|
// Raw provider exceptions can contain credentials; persist only the execution fact.
|
|
@@ -1157,6 +1190,7 @@ export class Session {
|
|
|
1157
1190
|
userText: UserInput,
|
|
1158
1191
|
signal?: AbortSignal,
|
|
1159
1192
|
pull?: (phase: Phase) => Promise<UserInput | null>,
|
|
1193
|
+
requestId?: string,
|
|
1160
1194
|
): AsyncGenerator<LoopEvent> {
|
|
1161
1195
|
// A crash after a discovery receipt but before its cut is recovered from that same receipt.
|
|
1162
1196
|
if (!signal?.aborted) await this.applyDiscoveredTools();
|
|
@@ -1170,9 +1204,8 @@ export class Session {
|
|
|
1170
1204
|
if (this.isHalfRound()) throw new Error("Cannot change image transmission while tool results are pending");
|
|
1171
1205
|
await this.performCut("model_checkpoint");
|
|
1172
1206
|
}
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
});
|
|
1207
|
+
const request = requestId ?? this.clock.nextId();
|
|
1208
|
+
await this.record({ t: "user", id: request, at: this.clock.now(), ...userInputData(userText, blocked) });
|
|
1176
1209
|
if (this.config.acceptanceContract) await this.record({ t: "acceptance_contract", id: this.clock.nextId(), at: this.clock.now(),
|
|
1177
1210
|
request: this.state.events.at(-1)!.id, contract: this.config.acceptanceContract });
|
|
1178
1211
|
// Idle changes belong to the new prompt. Recording them before `user`
|
|
@@ -1245,6 +1278,8 @@ export class Session {
|
|
|
1245
1278
|
try {
|
|
1246
1279
|
result = yield* this.awaitProvider(projected.messages, signal);
|
|
1247
1280
|
} catch (error) {
|
|
1281
|
+
// D11: retain a typed response receipt once, without anchoring rejected content or trusting arbitrary error fields.
|
|
1282
|
+
if (error instanceof FailedResponseError && error.usage !== undefined) this.state.usage.push({ ...error.usage });
|
|
1248
1283
|
if (signal?.aborted) {
|
|
1249
1284
|
yield* this.closeInterrupted(completedThisTurn);
|
|
1250
1285
|
return;
|
|
@@ -1270,6 +1305,14 @@ export class Session {
|
|
|
1270
1305
|
|
|
1271
1306
|
const previous = this.state.usage.at(-1);
|
|
1272
1307
|
this.state.usage.push(result.usage);
|
|
1308
|
+
// Q24: an adapter may finish after abort. Keep measured usage, but cancellation
|
|
1309
|
+
// owns termination and a late response cannot enter context or execute tools.
|
|
1310
|
+
if (signal?.aborted) {
|
|
1311
|
+
yield { kind: "usage", usage: result.usage, ok: true };
|
|
1312
|
+
yield { kind: "discard" };
|
|
1313
|
+
yield* this.closeInterrupted(completedThisTurn);
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1273
1316
|
const promptTokens =
|
|
1274
1317
|
result.usage.input_tokens +
|
|
1275
1318
|
result.usage.cache_creation_input_tokens +
|
|
@@ -1308,6 +1351,15 @@ export class Session {
|
|
|
1308
1351
|
};
|
|
1309
1352
|
}
|
|
1310
1353
|
|
|
1354
|
+
// D5: ambiguous IDs cannot be persisted or paired safely. Keep sampled
|
|
1355
|
+
// usage, but stop before observer hooks or any tool effect can run.
|
|
1356
|
+
const identityViolations = toolCallIdentityViolations(result.blocks);
|
|
1357
|
+
if (identityViolations.length > 0) {
|
|
1358
|
+
yield { kind: "done", outcome: executionOutcome("provider_error"),
|
|
1359
|
+
reason: `stopped: invalid provider tool call identity (${identityViolations.map(violation => `${violation.code}: ${violation.detail}`).join("; ")})` };
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1311
1363
|
await this.record({
|
|
1312
1364
|
t: "assistant", id: this.clock.nextId(), at: this.clock.now(), blocks: result.blocks, stop_reason: result.stopReason,
|
|
1313
1365
|
replay_key: providerReplayKey(this.provider.config.spec),
|
|
@@ -1330,7 +1382,7 @@ export class Session {
|
|
|
1330
1382
|
}
|
|
1331
1383
|
const extra = await this.pullSteering(pull);
|
|
1332
1384
|
if (extra !== null) {
|
|
1333
|
-
await this.record({ t: "user", id: this.clock.nextId(), at: this.clock.now(), ...userInputData(extra, blocked) });
|
|
1385
|
+
await this.record({ t: "user", id: this.clock.nextId(), at: this.clock.now(), request, ...userInputData(extra, blocked) });
|
|
1334
1386
|
continue;
|
|
1335
1387
|
}
|
|
1336
1388
|
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
@@ -1353,24 +1405,25 @@ export class Session {
|
|
|
1353
1405
|
const extra = await this.pullSteering(pull);
|
|
1354
1406
|
if (extra !== null) {
|
|
1355
1407
|
await this.record({
|
|
1356
|
-
t: "user", id: this.clock.nextId(), at: this.clock.now(), ...userInputData(extra, blocked),
|
|
1408
|
+
t: "user", id: this.clock.nextId(), at: this.clock.now(), request, ...userInputData(extra, blocked),
|
|
1357
1409
|
});
|
|
1358
1410
|
continue;
|
|
1359
1411
|
}
|
|
1360
1412
|
if (this.config.acceptanceContract) await this.refreshRuntimeTargets();
|
|
1361
|
-
const
|
|
1362
|
-
const
|
|
1363
|
-
:
|
|
1413
|
+
const state = await this.requestState();
|
|
1414
|
+
const decision = !state || this.config.guidance === false || this.config.harnessReviews === false ? { action: "finish" as const }
|
|
1415
|
+
: requestCompletion(state, this.config.maxTurns - turn - 1, {
|
|
1364
1416
|
...(this.config.guidanceVersion === 1 ? { taskAware: true } : {}),
|
|
1365
1417
|
...(this.config.strictVerification ? { strictVerification: true } : {}),
|
|
1366
|
-
...(acceptance ? { acceptance } : {}),
|
|
1367
1418
|
});
|
|
1368
|
-
if (
|
|
1369
|
-
await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), ...review });
|
|
1419
|
+
if (decision.action === "continue") {
|
|
1370
1420
|
if (yield* this.maybeCut(counter, true)) {
|
|
1371
1421
|
yield { kind: "done", reason: "stopped: no verifiable progress", outcome: executionOutcome("no_progress") };
|
|
1372
1422
|
return;
|
|
1373
1423
|
}
|
|
1424
|
+
// Q23: a notice appended before a cut can disappear from the next
|
|
1425
|
+
// projection. Append at the destination so the provider sees it once.
|
|
1426
|
+
await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), ...decision.guidance });
|
|
1374
1427
|
continue;
|
|
1375
1428
|
}
|
|
1376
1429
|
const stopped = yield* this.maybeCut(counter, false);
|
|
@@ -1450,7 +1503,7 @@ export class Session {
|
|
|
1450
1503
|
const extra = await this.pullSteering(pull);
|
|
1451
1504
|
if (extra !== null) {
|
|
1452
1505
|
await this.record({
|
|
1453
|
-
t: "user", id: this.clock.nextId(), at: this.clock.now(), ...userInputData(extra, blocked),
|
|
1506
|
+
t: "user", id: this.clock.nextId(), at: this.clock.now(), request, ...userInputData(extra, blocked),
|
|
1454
1507
|
});
|
|
1455
1508
|
}
|
|
1456
1509
|
}
|
package/src/runtime/model.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import type { ProviderApi } from "./provider-protocols.ts";
|
|
20
20
|
import { SOURCE_MODELS } from "./model-data.ts";
|
|
21
21
|
import { mergeCatalogs } from "../core/catalog-merge.ts";
|
|
22
|
+
import { GROK_PROVIDER, GROK_BASE_URL } from "./oauth.ts";
|
|
22
23
|
export type Api = ProviderApi;
|
|
23
24
|
|
|
24
25
|
/** How hard the model should think. Capability and wire vocabulary are mapped per model. */
|
|
@@ -440,6 +441,14 @@ export const CURATED_CATALOG: readonly ModelSpec[] = [
|
|
|
440
441
|
DEEPSEEK_V4_PRO,
|
|
441
442
|
DEEPSEEK_FLASH,
|
|
442
443
|
|
|
444
|
+
{
|
|
445
|
+
id: "grok-build", name: "Grok Build", provider: GROK_PROVIDER, baseUrl: GROK_BASE_URL,
|
|
446
|
+
api: "openai-completions", driver: "pi", input: ["text"],
|
|
447
|
+
// The official proxy alias has no published effort/price contract. Do not copy API model rates.
|
|
448
|
+
reasoning: false,
|
|
449
|
+
nativeCompat: { supportsDeveloperRole: false, supportsReasoningEffort: false, maxTokensField: "max_tokens" },
|
|
450
|
+
},
|
|
451
|
+
|
|
443
452
|
completionsModel({
|
|
444
453
|
id: "grok-4.6",
|
|
445
454
|
// Input modalities from the existing models.dev catalog source, 2026-09-30.
|
|
@@ -21,13 +21,14 @@ export async function nativeWire(request: Request): Promise<Record<string, unkno
|
|
|
21
21
|
return JSON.parse(new TextDecoder().decode(body)) as Record<string, unknown>;
|
|
22
22
|
}
|
|
23
23
|
/** Running the same serializer with a local fetch prevents a second, approximate wire builder. */
|
|
24
|
-
export async function nativePayload(config: ProviderConfig, frozen: FrozenPrefix, messages: Message[]): Promise<Record<string, unknown>> {
|
|
24
|
+
export async function nativePayload(config: ProviderConfig, frozen: FrozenPrefix, messages: Message[], sessionId?: string): Promise<Record<string, unknown>> {
|
|
25
25
|
let payload: Record<string, unknown> | undefined;
|
|
26
26
|
const capture = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
27
27
|
payload = await nativeWire(new Request(input, init));
|
|
28
28
|
return new Response(null, { status: 400 });
|
|
29
29
|
}) as typeof fetch;
|
|
30
|
-
const provider = new PiProvider(config, NATIVE_EVAL_TEST_KEY, { oauth: true, effects: noEffects, fetch: capture,
|
|
30
|
+
const provider = new PiProvider(config, NATIVE_EVAL_TEST_KEY, { oauth: true, effects: noEffects, fetch: capture,
|
|
31
|
+
...(sessionId ? { sessionId } : {}), retry: { maxRetries: 0, maxRetryDelayMs: 0 } });
|
|
31
32
|
await provider.send(frozen, messages, 0).catch(() => {});
|
|
32
33
|
if (!payload) throw Error("native offline wire was not produced");
|
|
33
34
|
return payload;
|
|
@@ -67,6 +68,7 @@ export function nativeResponseTap(body: ReadableStream<Uint8Array>, terminal: (r
|
|
|
67
68
|
export function nativeEvalTransport(options: {
|
|
68
69
|
config: ProviderConfig; key: string; price: BenchmarkPrice; ledger: ComparisonLedger; maxRequests: number;
|
|
69
70
|
fetch?: typeof fetch;
|
|
71
|
+
sessionId?: string;
|
|
70
72
|
record: (row: GatewayRecord) => Promise<void>;
|
|
71
73
|
wire: (payload: Record<string, unknown>, request: number) => Promise<void>;
|
|
72
74
|
response: (receipt: NativeResponse, request: number) => Promise<void>;
|
|
@@ -105,7 +107,8 @@ export function nativeEvalTransport(options: {
|
|
|
105
107
|
state.terminal = terminal; await options.response(terminal, row.request);
|
|
106
108
|
}), { status: response.status, headers: response.headers });
|
|
107
109
|
}) as typeof fetch;
|
|
108
|
-
const actual = new PiProvider(config, options.key, { oauth: true, effects: noEffects, fetch: observedFetch,
|
|
110
|
+
const actual = new PiProvider(config, options.key, { oauth: true, effects: noEffects, fetch: observedFetch,
|
|
111
|
+
...(options.sessionId ? { sessionId: options.sessionId } : {}), retry: { maxRetries: 0, maxRetryDelayMs: 0 } });
|
|
109
112
|
const provider: Provider = { config, async send(frozen, messages, node, signal, onDelta) {
|
|
110
113
|
if (active) throw Error("native eval model requests must be sequential");
|
|
111
114
|
active = true; current = undefined;
|
package/src/runtime/oauth.ts
CHANGED
|
@@ -15,9 +15,10 @@
|
|
|
15
15
|
|
|
16
16
|
import { createServer, type Server } from "node:http";
|
|
17
17
|
import { pollDeviceCode } from "./oauth-device.ts";
|
|
18
|
+
import { parseDeviceAuthorization, parseOAuthTokens } from "../core/oauth-fields.ts";
|
|
18
19
|
|
|
19
20
|
/** Matches package.json. Sent as User-Agent so the server can see who we are (D18). */
|
|
20
|
-
export const XENO_VERSION = "0.
|
|
21
|
+
export const XENO_VERSION = "0.5.0";
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Minutes subtracted from server expiry so a request issued at the edge
|
|
@@ -33,6 +34,7 @@ export const SUBSCRIPTION_OPTIONS = [
|
|
|
33
34
|
{ id: "claude-pro", provider: "anthropic", label: "Claude Pro/Max" },
|
|
34
35
|
{ id: "codex", provider: "openai-codex", label: "ChatGPT Plus/Pro (Codex)" },
|
|
35
36
|
{ id: "github-copilot", provider: "github-copilot", label: "GitHub Copilot" },
|
|
37
|
+
{ id: "grok", provider: "xai-grok", label: "Grok (official account)" },
|
|
36
38
|
] as const;
|
|
37
39
|
|
|
38
40
|
export type SubscriptionId = (typeof SUBSCRIPTION_OPTIONS)[number]["id"];
|
|
@@ -101,12 +103,83 @@ export const OPENAI_REDIRECT_URI = `http://localhost:${OPENAI_CALLBACK_PORT}${OP
|
|
|
101
103
|
const OPENAI_SCOPE = "openid profile email offline_access";
|
|
102
104
|
const OPENAI_JWT_CLAIM = "https://api.openai.com/auth";
|
|
103
105
|
|
|
106
|
+
// Official Grok Build config.rs / device_code.rs, verified 2026-10-07.
|
|
107
|
+
// This is the public first-party client, with xeno attribution retained (D18).
|
|
108
|
+
const GROK_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
|
|
109
|
+
const GROK_AUTH_ORIGIN = "https://auth.x.ai";
|
|
110
|
+
const GROK_TOKEN_URL = `${GROK_AUTH_ORIGIN}/oauth2/token`;
|
|
111
|
+
// Only inference and refresh scopes: xeno does not store remote conversations/workspaces.
|
|
112
|
+
const GROK_SCOPE = "openid profile email offline_access grok-cli:access api:access";
|
|
113
|
+
export const GROK_PROVIDER = "xai-grok";
|
|
114
|
+
export const GROK_BASE_URL = "https://cli-chat-proxy.grok.com/v1";
|
|
115
|
+
/** Official HTTP timeout: issuer requests cannot keep a cancelled or expired grant hanging indefinitely. */
|
|
116
|
+
const GROK_REQUEST_TIMEOUT_MS = 15_000;
|
|
117
|
+
|
|
118
|
+
async function grokPost(url: string, fields: Record<string, string>, effects: Pick<RefreshEffects, "fetch" | "signal">): Promise<{ status: number; json: unknown }> {
|
|
119
|
+
const timeout = AbortSignal.timeout(GROK_REQUEST_TIMEOUT_MS);
|
|
120
|
+
const signal = effects.signal ? AbortSignal.any([effects.signal, timeout]) : timeout;
|
|
121
|
+
try {
|
|
122
|
+
const response = await effects.fetch(url, { method: "POST", redirect: "error", signal,
|
|
123
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", "User-Agent": `xeno/${XENO_VERSION}` },
|
|
124
|
+
body: new URLSearchParams(fields) });
|
|
125
|
+
let json: unknown;
|
|
126
|
+
try { json = await response.json(); } catch { json = undefined; }
|
|
127
|
+
return { status: response.status, json };
|
|
128
|
+
} catch {
|
|
129
|
+
// Network errors can include request details; never expose a credential-bearing body.
|
|
130
|
+
throw new Error(effects.signal?.aborted ? "Login cancelled" : "Grok authentication request failed");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function loginGrok(interaction: OAuthInteraction, sleep?: Parameters<typeof pollDeviceCode>[0]["sleep"]): Promise<OAuthCredential> {
|
|
135
|
+
const started = await grokPost(`${GROK_AUTH_ORIGIN}/oauth2/device/code`,
|
|
136
|
+
{ client_id: GROK_CLIENT_ID, scope: GROK_SCOPE, referrer: "xeno" }, interaction);
|
|
137
|
+
if (started.status < 200 || started.status >= 300) throw new Error(`Grok device code request failed (HTTP ${started.status})`);
|
|
138
|
+
const device = parseDeviceAuthorization(started.json, [GROK_AUTH_ORIGIN, "https://accounts.x.ai"]);
|
|
139
|
+
if (!device) throw new Error("Grok device code response was incomplete or untrusted");
|
|
140
|
+
interaction.signal.throwIfAborted();
|
|
141
|
+
const url = device.verificationUriComplete ?? device.verificationUri;
|
|
142
|
+
interaction.notify(`Open ${url}\nand enter code: ${device.userCode}`);
|
|
143
|
+
tryOpen(url, interaction.openBrowser);
|
|
144
|
+
return pollDeviceCode<OAuthCredential>({
|
|
145
|
+
expiresInSeconds: device.expiresIn, waitBeforeFirstPoll: true, signal: interaction.signal,
|
|
146
|
+
...(device.interval !== undefined ? { intervalSeconds: device.interval } : {}), ...(sleep ? { sleep } : {}),
|
|
147
|
+
poll: async () => {
|
|
148
|
+
const got = await grokPost(GROK_TOKEN_URL,
|
|
149
|
+
{ client_id: GROK_CLIENT_ID, device_code: device.deviceCode, grant_type: "urn:ietf:params:oauth:grant-type:device_code" }, interaction);
|
|
150
|
+
const body = asRecord(got.json);
|
|
151
|
+
// RFC 8628 errors use HTTP 400; they are polling states, never completed logins.
|
|
152
|
+
if (got.status === 400 && body?.["error"] === "authorization_pending") return { status: "pending" };
|
|
153
|
+
if (got.status === 400 && body?.["error"] === "slow_down") return { status: "slow_down" };
|
|
154
|
+
if (got.status < 200 || got.status >= 300) return { status: "failed", message: `Grok device login failed (HTTP ${got.status})` };
|
|
155
|
+
const fields = parseOAuthTokens(got.json);
|
|
156
|
+
if (!fields) return { status: "failed", message: "Grok token response was incomplete" };
|
|
157
|
+
interaction.signal.throwIfAborted();
|
|
158
|
+
return { status: "complete", value: { type: "oauth", access: fields.access, refresh: fields.refresh,
|
|
159
|
+
expires: expiryFromExpiresIn(fields.expiresIn, (interaction.now ?? Date.now)()) } };
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function refreshGrok(refresh: string, effects: RefreshEffects): Promise<{ ok: true; credential: OAuthCredential } | { ok: false; reason: string }> {
|
|
165
|
+
try {
|
|
166
|
+
const got = await grokPost(GROK_TOKEN_URL, { client_id: GROK_CLIENT_ID, grant_type: "refresh_token", refresh_token: refresh }, effects);
|
|
167
|
+
if (effects.signal?.aborted) return { ok: false, reason: "Login cancelled" };
|
|
168
|
+
if (got.status < 200 || got.status >= 300) return { ok: false, reason: `HTTP ${got.status}` };
|
|
169
|
+
const fields = parseOAuthTokens(got.json, refresh);
|
|
170
|
+
if (!fields) return { ok: false, reason: "unexpected body" };
|
|
171
|
+
return { ok: true, credential: { type: "oauth", access: fields.access, refresh: fields.refresh,
|
|
172
|
+
expires: expiryFromExpiresIn(fields.expiresIn, effects.now()) } };
|
|
173
|
+
} catch { return { ok: false, reason: "Grok token refresh failed" }; }
|
|
174
|
+
}
|
|
175
|
+
|
|
104
176
|
export function isSubscriptionLogin(id: string): boolean {
|
|
105
|
-
return
|
|
177
|
+
return subscriptionById(id) !== undefined;
|
|
106
178
|
}
|
|
107
179
|
|
|
108
180
|
export function subscriptionById(id: string): (typeof SUBSCRIPTION_OPTIONS)[number] | undefined {
|
|
109
|
-
|
|
181
|
+
// Separate account providers accept their catalog identity too; anthropic still means an API key.
|
|
182
|
+
return SUBSCRIPTION_OPTIONS.find(o => o.id === id || (o.provider === id && o.provider !== "anthropic"));
|
|
110
183
|
}
|
|
111
184
|
|
|
112
185
|
export function expiryFromExpiresIn(expiresIn: number, now: number): number {
|
|
@@ -505,6 +578,8 @@ export async function refreshOAuthCredential(
|
|
|
505
578
|
return refreshOpenAI(credential.refresh, opts);
|
|
506
579
|
case "github-copilot":
|
|
507
580
|
return refreshCopilot(credential.refresh, opts, credential.enterpriseUrl);
|
|
581
|
+
case GROK_PROVIDER:
|
|
582
|
+
return refreshGrok(credential.refresh, opts);
|
|
508
583
|
default:
|
|
509
584
|
return { ok: false, reason: `no oauth refresh for ${provider}` };
|
|
510
585
|
}
|
|
@@ -752,6 +827,8 @@ export async function loginSubscription(
|
|
|
752
827
|
return { provider: "openai-codex", credential: await loginOpenAICodex(interaction) };
|
|
753
828
|
case "github-copilot":
|
|
754
829
|
return { provider: "github-copilot", credential: await loginGitHubCopilot(interaction) };
|
|
830
|
+
case "grok":
|
|
831
|
+
return { provider: GROK_PROVIDER, credential: await loginGrok(interaction) };
|
|
755
832
|
default: {
|
|
756
833
|
const never: never = id;
|
|
757
834
|
throw new Error(`unknown subscription login: ${String(never)}`);
|