carthagent 0.0.0-stage → 1.0.7
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 +219 -2
- package/bin/ctg.mjs +736 -0
- package/docs/budgets.md +72 -0
- package/docs/compaction.md +34 -0
- package/docs/context-efficiency-plan.md +24 -0
- package/docs/context-efficiency-review.md +27 -0
- package/docs/context-efficiency.md +49 -0
- package/docs/deepseek-jev-benchmark-plan.md +70 -0
- package/docs/deepseek-jev-benchmark-repair-plan.md +20 -0
- package/docs/deepseek-jev-benchmark-restart.md +9 -0
- package/docs/deepseek-jev-benchmark-results.md +77 -0
- package/docs/deepseek-jev-benchmark-running.md +60 -0
- package/docs/deepseek-jev-benchmark-v3-results.md +41 -0
- package/docs/deepseek-jev-benchmark-v4-protocol.md +15 -0
- package/docs/deepseek-jev-benchmark-v4-results.md +119 -0
- package/docs/deepseek-jev-http400-diagnosis.md +45 -0
- package/docs/delivery-review-evidence.md +53 -0
- package/docs/delivery-timeout-repair-plan.md +51 -0
- package/docs/evaluation-readiness.md +31 -0
- package/docs/evaluation.md +70 -0
- package/docs/extending.md +354 -0
- package/docs/glm-pilot-execution.md +9 -0
- package/docs/glm-pilot-plan.md +15 -0
- package/docs/glm-pilot-results.md +71 -0
- package/docs/graph-context-optimization.md +15 -0
- package/docs/jev-repository-pilot-plan.md +25 -0
- package/docs/jev-repository-pilot-protocol.md +39 -0
- package/docs/jev-repository-pilot-restart.md +7 -0
- package/docs/jev-repository-pilot-results.md +79 -0
- package/docs/jev.md +32 -0
- package/docs/model-registry.md +25 -0
- package/docs/native-compatibility.md +117 -0
- package/docs/opencode-archive.md +9 -0
- package/docs/opencode-migration.md +115 -0
- package/docs/opencode-runtime.md +94 -0
- package/docs/opencode-workspace.md +31 -0
- package/docs/owned-runtime-workflow-registration.md +17 -0
- package/docs/plan-advisor-evaluation.md +38 -0
- package/docs/planning-startup-fix-plan.md +23 -0
- package/docs/planning-startup-fix-review.md +24 -0
- package/docs/planning.md +118 -0
- package/docs/production-merge-review.md +23 -0
- package/docs/quality.md +35 -0
- package/docs/repository-benchmark-design.md +111 -0
- package/docs/review-assurance.md +9 -0
- package/docs/review-experiment-results.md +59 -0
- package/docs/tested-planning-roadmap.md +58 -0
- package/docs/tested-planning.md +58 -0
- package/docs/tui-pickers.md +9 -0
- package/docs/webdriver.md +83 -0
- package/docs/workflow-modes.md +26 -0
- package/docs/workflow.d2 +17 -0
- package/docs/workflow.svg +149 -0
- package/docs/workspace-coordination.md +9 -0
- package/evaluate.mjs +249 -0
- package/extensions/codegraph.ts +36 -0
- package/extensions/delivery.ts +819 -0
- package/extensions/experiential.ts +21 -0
- package/extensions/subscriptions.ts +21 -0
- package/guidance/profiles/authenticated-web.json +24 -0
- package/guidance/profiles/data-visualization.json +25 -0
- package/guidance/profiles/database-change.json +24 -0
- package/guidance/profiles/external-api.json +24 -0
- package/guidance/profiles/transactional-data.json +25 -0
- package/guidance/profiles/web-application.json +26 -0
- package/lib/acceptance-review.mjs +4 -0
- package/lib/browser-bin.mjs +160 -0
- package/lib/browser-errors.mjs +54 -0
- package/lib/browser.mjs +219 -0
- package/lib/budget.mjs +37 -0
- package/lib/cdp.mjs +221 -0
- package/lib/cloud/client.mjs +275 -0
- package/lib/cloud/control-plane.mjs +350 -0
- package/lib/cloud/gateway.mjs +380 -0
- package/lib/cloud/http.mjs +135 -0
- package/lib/cloud/ledger.mjs +215 -0
- package/lib/codegraph-query.mjs +60 -0
- package/lib/codegraph.mjs +378 -0
- package/lib/context-archive.mjs +18 -0
- package/lib/delivery-context.mjs +132 -0
- package/lib/delivery-guidance.mjs +9 -0
- package/lib/delivery-review.mjs +94 -0
- package/lib/delivery.mjs +610 -0
- package/lib/engine.mjs +137 -0
- package/lib/guidance.mjs +71 -0
- package/lib/jev-advisor.mjs +47 -0
- package/lib/opencode/archive.mjs +122 -0
- package/lib/opencode/compatibility.mjs +19 -0
- package/lib/opencode/credentials.mjs +205 -0
- package/lib/opencode/evaluation-budget-plugin/index.ts +147 -0
- package/lib/opencode/evaluation.mjs +242 -0
- package/lib/opencode/file-lock.mjs +86 -0
- package/lib/opencode/headless.mjs +200 -0
- package/lib/opencode/review.mjs +213 -0
- package/lib/opencode/runtime.mjs +246 -0
- package/lib/opencode/service.mjs +249 -0
- package/lib/opencode/session-options.mjs +60 -0
- package/lib/opencode/workspace.mjs +133 -0
- package/lib/openrouter-jev.mjs +37 -0
- package/lib/outcome-contract.mjs +21 -0
- package/lib/plan-advisor.mjs +38 -0
- package/lib/planning-access.mjs +7 -0
- package/lib/planning-advice.mjs +28 -0
- package/lib/planning-discovery.mjs +14 -0
- package/lib/planning-guide.mjs +34 -0
- package/lib/planning-view.mjs +57 -0
- package/lib/planning.mjs +155 -0
- package/lib/probe-diagnostics.mjs +13 -0
- package/lib/providers/experiential.mjs +292 -0
- package/lib/providers/model-catalog.json +700 -0
- package/lib/providers/model-catalog.mjs +7 -0
- package/lib/providers/names.mjs +13 -0
- package/lib/providers/subscription-models.mjs +483 -0
- package/lib/reports.mjs +80 -0
- package/lib/review-state.mjs +49 -0
- package/lib/review.mjs +114 -0
- package/lib/scenarios.mjs +99 -0
- package/lib/ship-auth.mjs +44 -0
- package/lib/ship-jev.mjs +59 -0
- package/lib/ship-telemetry.mjs +183 -0
- package/lib/tui/ansi.mjs +255 -0
- package/lib/tui/app.mjs +2445 -0
- package/lib/tui/auth.mjs +249 -0
- package/lib/tui/carthage.mjs +109 -0
- package/lib/tui/clipboard.mjs +122 -0
- package/lib/tui/d2.mjs +431 -0
- package/lib/tui/feed.mjs +467 -0
- package/lib/tui/framing.mjs +80 -0
- package/lib/tui/glyphs.mjs +172 -0
- package/lib/tui/hash-worker.mjs +11 -0
- package/lib/tui/mock-engine.mjs +222 -0
- package/lib/tui/models.mjs +73 -0
- package/lib/tui/picker.mjs +18 -0
- package/lib/tui/theme.mjs +663 -0
- package/lib/tui/title.mjs +7 -0
- package/lib/update-channel.mjs +62 -0
- package/lib/update.mjs +230 -0
- package/lib/webdriver.mjs +82 -0
- package/lib/workflow.mjs +88 -0
- package/lib/workspace.mjs +39 -0
- package/metadata.json +8 -0
- package/package.json +94 -4
- package/plugins/carthagent/index.ts +3 -0
- package/plugins/carthagent/package.json +28 -0
- package/plugins/carthagent/src/archive-rpc.ts +46 -0
- package/plugins/carthagent/src/bootstrap.ts +38 -0
- package/plugins/carthagent/src/index.ts +62 -0
- package/plugins/carthagent/src/pull-request-rpc.ts +54 -0
- package/plugins/carthagent/src/rpc.ts +76 -0
- package/plugins/carthagent/src/server/archive-rpc.ts +327 -0
- package/plugins/carthagent/src/server/budgets.ts +21 -0
- package/plugins/carthagent/src/server/codegraph.ts +46 -0
- package/plugins/carthagent/src/server/delivery.ts +488 -0
- package/plugins/carthagent/src/server/device-auth.ts +91 -0
- package/plugins/carthagent/src/server/native-delivery.ts +26 -0
- package/plugins/carthagent/src/server/orchestration.ts +56 -0
- package/plugins/carthagent/src/server/policy.ts +24 -0
- package/plugins/carthagent/src/server/providers.ts +424 -0
- package/plugins/carthagent/src/server/pull-request-rpc.ts +387 -0
- package/plugins/carthagent/src/server/session-controls.ts +82 -0
- package/plugins/carthagent/src/server/telemetry.ts +92 -0
- package/plugins/carthagent/src/server/workspace-rpc.ts +349 -0
- package/plugins/carthagent/src/session-rpc.ts +31 -0
- package/plugins/carthagent/src/testing.ts +85 -0
- package/plugins/carthagent/src/workspace-rpc.ts +133 -0
- package/plugins/carthagent/test-plugin/index.ts +4 -0
- package/plugins/carthagent/test-plugin/package.json +1 -0
- package/plugins/carthagent/tsconfig.json +19 -0
- package/plugins/carthagent/tui.ts +1 -0
- package/prompts/delivery.md +7 -0
- package/public/index.html +983 -0
- package/scenarios/cli/grade_cli.py +69 -0
- package/scenarios/cli/scenario.json +21 -0
- package/scenarios/game/baseline/minecraft.py +193 -0
- package/scenarios/game/context.md +1 -0
- package/scenarios/game/grade_minecraft_clone.py +603 -0
- package/scenarios/game/requirements.txt +2 -0
- package/scenarios/game/scenario.json +33 -0
- package/server.mjs +341 -0
- package/skills/game-development/SKILL.md +36 -0
- package/skills/game-development/assets/ursina_starter.py +98 -0
- package/skills/game-development/scripts/verify_ursina.py +80 -0
- package/skills/software-delivery/SKILL.md +50 -0
- package/skills/typescript-delivery/SKILL.md +69 -0
- package/tests/acceptance-review.test.mjs +13 -0
- package/tests/auth.test.mjs +234 -0
- package/tests/bounded-summary.test.mjs +68 -0
- package/tests/browser-bin.test.mjs +91 -0
- package/tests/browser.test.mjs +96 -0
- package/tests/budget-engine.test.mjs +110 -0
- package/tests/budget.test.mjs +23 -0
- package/tests/cli.test.mjs +245 -0
- package/tests/clipboard.test.mjs +79 -0
- package/tests/cloud-client.test.mjs +129 -0
- package/tests/cloud-control-plane.test.mjs +144 -0
- package/tests/cloud-gateway.test.mjs +147 -0
- package/tests/cloud-http.test.mjs +102 -0
- package/tests/cloud-ledger.test.mjs +75 -0
- package/tests/codegraph-query.test.mjs +66 -0
- package/tests/codegraph.test.mjs +142 -0
- package/tests/delivery-context.test.mjs +66 -0
- package/tests/delivery-review.test.mjs +139 -0
- package/tests/delivery.test.mjs +343 -0
- package/tests/engine-api.mjs +24 -0
- package/tests/experiential-extension.test.mjs +37 -0
- package/tests/experiential.test.mjs +300 -0
- package/tests/extension.test.mjs +921 -0
- package/tests/fixtures/archive-plugin/index.ts +10 -0
- package/tests/guidance.test.mjs +78 -0
- package/tests/model-registry.test.mjs +68 -0
- package/tests/models.test.mjs +70 -0
- package/tests/openai-idempotency.test.mjs +99 -0
- package/tests/opencode-archive-rpc.test.mjs +82 -0
- package/tests/opencode-delivery.test.mjs +231 -0
- package/tests/opencode-providers.test.mjs +414 -0
- package/tests/opencode-runtime.test.mjs +455 -0
- package/tests/opencode-session-controls.test.mjs +115 -0
- package/tests/opencode-session-options.test.mjs +26 -0
- package/tests/opencode-workspace.test.mjs +339 -0
- package/tests/outcome-contract.test.mjs +17 -0
- package/tests/picker.test.mjs +34 -0
- package/tests/plan-advisor.test.mjs +88 -0
- package/tests/plan-review-loop.test.mjs +53 -0
- package/tests/planning-advice.test.mjs +27 -0
- package/tests/planning-contract.test.mjs +65 -0
- package/tests/planning-startup.test.mjs +38 -0
- package/tests/planning-view.test.mjs +33 -0
- package/tests/planning.test.mjs +223 -0
- package/tests/probe-diagnostics.test.mjs +21 -0
- package/tests/pull-request-rpc.test.mjs +366 -0
- package/tests/review-state.test.mjs +32 -0
- package/tests/review.test.mjs +127 -0
- package/tests/server.test.mjs +252 -0
- package/tests/ship-jev.test.mjs +77 -0
- package/tests/ship-telemetry.test.mjs +333 -0
- package/tests/standard-workflow.test.mjs +28 -0
- package/tests/subscription-models.test.mjs +416 -0
- package/tests/subscriptions-extension.test.mjs +27 -0
- package/tests/terminal-title.test.mjs +13 -0
- package/tests/test_grade_minecraft_clone.py +199 -0
- package/tests/test_starter.py +78 -0
- package/tests/theme.test.mjs +175 -0
- package/tests/tui-copy-paste.test.mjs +54 -0
- package/tests/tui.test.mjs +808 -0
- package/tests/update-channels.test.mjs +129 -0
- package/tests/update.test.mjs +175 -0
- package/tests/webdriver.test.mjs +31 -0
- package/tests/workflow.test.mjs +47 -0
- package/tests/workspace-rpc.test.mjs +236 -0
- package/tests/workspace.test.mjs +24 -0
- package/tools/benchmark/advisor.test.mjs +7 -0
- package/tools/benchmark/advisor.ts +27 -0
- package/tools/benchmark/campaign.mjs +45 -0
- package/tools/benchmark/diagnose-request.mjs +40 -0
- package/tools/benchmark/eval-common.mjs +32 -0
- package/tools/benchmark/eval-ledger.mjs +22 -0
- package/tools/benchmark/eval-shop.mjs +26 -0
- package/tools/benchmark/eval-voxel.mjs +20 -0
- package/tools/benchmark/evaluate.mjs +24 -0
- package/tools/benchmark/evaluators.test.mjs +13 -0
- package/tools/benchmark/fixtures/shop/server.mjs +18 -0
- package/tools/benchmark/fixtures/shop/ui.html +8 -0
- package/tools/benchmark/freeze.mjs +30 -0
- package/tools/benchmark/gateway.mjs +91 -0
- package/tools/benchmark/gateway.test.mjs +38 -0
- package/tools/benchmark/generator.mjs +11 -0
- package/tools/benchmark/generator.test.mjs +26 -0
- package/tools/benchmark/guard.test.mjs +133 -0
- package/tools/benchmark/guard.ts +136 -0
- package/tools/benchmark/native-plugin/index.ts +24 -0
- package/tools/benchmark/planning-startup.test.mjs +47 -0
- package/tools/benchmark/preflight-live.mjs +31 -0
- package/tools/benchmark/preflight.mjs +22 -0
- package/tools/benchmark/replay-diagnostic.mjs +23 -0
- package/tools/benchmark/runner.mjs +141 -0
- package/tools/benchmark/runner.test.mjs +93 -0
- package/tools/benchmark/runtime.mjs +23 -0
- package/tools/benchmark/selftest.mjs +47 -0
- package/tools/benchmark/smoke-jev.mjs +5 -0
- package/tools/benchmark/tasks.mjs +20 -0
- package/tools/benchmark/transport.mjs +65 -0
- package/tools/benchmark/transport.test.mjs +44 -0
- package/tools/benchmark/upstream-error.mjs +18 -0
- package/tools/benchmark/upstream-error.test.mjs +10 -0
- package/tools/browser-check.mjs +109 -0
- package/tools/check-syntax.mjs +27 -0
- package/tools/evaluate-plan-advisor.mjs +20 -0
- package/tools/native-fixture.mjs +194 -0
- package/tools/native-pty-smoke.py +108 -0
- package/tools/native-report-plugin/index.ts +50 -0
- package/tools/native-report-plugin/package.json +7 -0
- package/tools/patch-compaction.mjs +24 -0
- package/tools/patch-model-catalog.mjs +12 -0
- package/tools/patch-print-shutdown.mjs +39 -0
- package/tools/repo-pilot/evaluator.cjs +32 -0
- package/tools/repo-pilot/reference.mjs +43 -0
- package/tools/repo-pilot/runner.mjs +85 -0
- package/tools/repo-pilot/sandbox-entry.mjs +12 -0
- package/tools/repo-pilot/tasks.mjs +20 -0
- package/tools/repo-pilot/type-evaluator.cjs +6 -0
- package/tools/test-native-controls.mjs +84 -0
- package/tools/test-native-delivery-host.mjs +307 -0
- package/tools/test-native-evaluation.mjs +46 -0
- package/tools/test-native-host.mjs +385 -0
- package/tools/test-native-outcomes.mjs +73 -0
- package/tools/test-native-package.mjs +70 -0
- package/tools/test-native-pty.mjs +20 -0
- package/tools/test-native-report.mjs +88 -0
- package/tools/test-native-swe-host.mjs +132 -0
- package/tools/test-native-swe.mjs +122 -0
- package/tools/test-picker-pty.py +45 -0
- package/tools/test-python.mjs +29 -0
- package/tools/test-webdriver.mjs +54 -0
- package/tools/verify-release-version.mjs +37 -0
- package/tools/webdriver-check.mjs +52 -0
- package/vendor/agent/bounded-summary.mjs +88 -0
- package/vendor/agent/chunks/anthropic-messages-VWZZOSJQ.js +6 -0
- package/vendor/agent/chunks/anthropic.js +75 -0
- package/vendor/agent/chunks/azure-openai-responses-BJLAM6CD.js +2 -0
- package/vendor/agent/chunks/bedrock-converse-stream.js +74 -0
- package/vendor/agent/chunks/chord-context.js +96 -0
- package/vendor/agent/chunks/chunk-4L3WN2XY.js +2 -0
- package/vendor/agent/chunks/chunk-6CYCI3EJ.js +12 -0
- package/vendor/agent/chunks/chunk-6R7UI4IY.js +2 -0
- package/vendor/agent/chunks/chunk-7ZC4UXSL.js +2 -0
- package/vendor/agent/chunks/chunk-AXIIZGTV.js +2 -0
- package/vendor/agent/chunks/chunk-CLNPYIDP.js +42 -0
- package/vendor/agent/chunks/chunk-DRQDKNXA.js +2 -0
- package/vendor/agent/chunks/chunk-GMWCTUPB.js +2 -0
- package/vendor/agent/chunks/chunk-IDDQWTHI.js +2 -0
- package/vendor/agent/chunks/chunk-JVUZSMYM.js +1548 -0
- package/vendor/agent/chunks/chunk-L2ZPNLYD.js +2 -0
- package/vendor/agent/chunks/chunk-NUHFSC37.js +25 -0
- package/vendor/agent/chunks/chunk-OUXLLA64.js +11 -0
- package/vendor/agent/chunks/chunk-PGXDEOVV.js +2 -0
- package/vendor/agent/chunks/chunk-PJACZCIR.js +2 -0
- package/vendor/agent/chunks/chunk-PRRNXB7S.js +2 -0
- package/vendor/agent/chunks/chunk-TEPMHNKQ.js +2 -0
- package/vendor/agent/chunks/chunk-TRDNDS6A.js +4 -0
- package/vendor/agent/chunks/chunk-U6ZMQKGD.js +50 -0
- package/vendor/agent/chunks/chunk-UAQELI3K.js +2 -0
- package/vendor/agent/chunks/chunk-USJZMAIR.js +2 -0
- package/vendor/agent/chunks/chunk-XDUWOHPL.js +6 -0
- package/vendor/agent/chunks/chunk-XNGRGP62.js +2 -0
- package/vendor/agent/chunks/github-copilot.js +2 -0
- package/vendor/agent/chunks/google-generative-ai-Q2K7FBDI.js +2 -0
- package/vendor/agent/chunks/google-vertex-7UJBF4S7.js +2 -0
- package/vendor/agent/chunks/https-proxy-agent-2VXB7436.js +13 -0
- package/vendor/agent/chunks/image-resize-worker.js +2 -0
- package/vendor/agent/chunks/kimi-coding.js +2 -0
- package/vendor/agent/chunks/lib-HGQMNYVT.js +9 -0
- package/vendor/agent/chunks/mistral-conversations-4M6BZBX6.js +5 -0
- package/vendor/agent/chunks/multipart-parser-SQ6GDJL7.js +3 -0
- package/vendor/agent/chunks/node-KG362ECQ.js +16 -0
- package/vendor/agent/chunks/node-TWVVAGPO.js +2 -0
- package/vendor/agent/chunks/openai-codex-responses-R6VYZTVY.js +10 -0
- package/vendor/agent/chunks/openai-codex.js +75 -0
- package/vendor/agent/chunks/openai-completions-EKZT2IH2.js +7 -0
- package/vendor/agent/chunks/openai-responses-TFDINO6W.js +2 -0
- package/vendor/agent/chunks/openrouter-images-N4XVVFSD.js +2 -0
- package/vendor/agent/chunks/openrouter.js +75 -0
- package/vendor/agent/chunks/pi-messages-PML4SPVJ.js +8 -0
- package/vendor/agent/chunks/radius.js +75 -0
- package/vendor/agent/chunks/src-UK77SYZE.js +4 -0
- package/vendor/agent/chunks/xai.js +2 -0
- package/vendor/agent/cli.js +3 -0
- package/vendor/agent/dist/modes/interactive/theme/dark.json +91 -0
- package/vendor/agent/dist/modes/interactive/theme/light.json +90 -0
- package/vendor/agent/dist/modes/interactive/theme/theme-schema.json +356 -0
- package/vendor/agent/index.js +2 -0
- package/vendor/agent/modes/interactive/theme/dark.json +91 -0
- package/vendor/agent/modes/interactive/theme/light.json +90 -0
- package/vendor/agent/modes/interactive/theme/theme-schema.json +356 -0
- package/vendor/agent/package.json +14 -0
- package/vendor/agent/rpc-entry.js +3 -0
- package/vendor/agent/theme/dark.json +91 -0
- package/vendor/agent/theme/light.json +90 -0
- package/vendor/agent/theme/theme-schema.json +356 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import {
|
|
7
|
+
ANTHROPIC_PROVIDER_ID,
|
|
8
|
+
OPENAI_CODEX_PROVIDER_ID,
|
|
9
|
+
anthropicDiscoveryProviderConfig,
|
|
10
|
+
codexDiscoveryProviderConfig,
|
|
11
|
+
codexModelDefinition,
|
|
12
|
+
codexThinkingLevelMap,
|
|
13
|
+
credentialKey,
|
|
14
|
+
decodeChatGptAccountId,
|
|
15
|
+
fileSubscriptionCache,
|
|
16
|
+
matchCachedDiscovery,
|
|
17
|
+
mergeDiscoveredModels,
|
|
18
|
+
parseAnthropicModels,
|
|
19
|
+
parseCodexModels,
|
|
20
|
+
registerSubscriptionProviders,
|
|
21
|
+
} from '../lib/providers/subscription-models.mjs';
|
|
22
|
+
|
|
23
|
+
function jsonResponse(payload, status = 200) {
|
|
24
|
+
return {
|
|
25
|
+
ok: status >= 200 && status < 300,
|
|
26
|
+
status,
|
|
27
|
+
json: async () => payload,
|
|
28
|
+
text: async () => JSON.stringify(payload),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function fakeJwt(claims) {
|
|
33
|
+
return ['h', Buffer.from(JSON.stringify(claims)).toString('base64url'), 's'].join('.');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const signal = () => new AbortController().signal;
|
|
37
|
+
const noKnown = async () => [];
|
|
38
|
+
const memoryCache = () => {
|
|
39
|
+
const entries = new Map();
|
|
40
|
+
return { read: async id => entries.get(id) ?? null, write: async (id, entry) => entries.set(id, entry), entries };
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
test('anthropic discovery fetches /v1/models with the subscription OAuth headers', async () => {
|
|
44
|
+
const calls = [];
|
|
45
|
+
const fetchImpl = async (url, options) => {
|
|
46
|
+
calls.push({ url: String(url), options });
|
|
47
|
+
return jsonResponse({ data: [
|
|
48
|
+
{ type: 'model', id: 'claude-opus-9', display_name: 'Claude Opus 9' },
|
|
49
|
+
{ type: 'model', id: 'claude-sonnet-9', display_name: 'Claude Sonnet 9' },
|
|
50
|
+
], has_more: false });
|
|
51
|
+
};
|
|
52
|
+
const config = anthropicDiscoveryProviderConfig({ fetchImpl, knownModels: noKnown });
|
|
53
|
+
const refreshed = await config.refreshModels({
|
|
54
|
+
allowNetwork: true,
|
|
55
|
+
credential: { type: 'oauth', access: 'sk-ant-oat', expires: Date.now() + 60_000 },
|
|
56
|
+
signal: signal(),
|
|
57
|
+
stored: undefined,
|
|
58
|
+
});
|
|
59
|
+
assert.equal(calls.length, 1);
|
|
60
|
+
assert.match(calls[0].url, /^https:\/\/api\.anthropic\.com\/v1\/models\?/);
|
|
61
|
+
assert.equal(calls[0].options.headers.authorization, 'Bearer sk-ant-oat');
|
|
62
|
+
assert.equal(calls[0].options.headers['anthropic-beta'], 'oauth-2025-04-20');
|
|
63
|
+
assert.equal(calls[0].options.headers['anthropic-version'], '2023-06-01');
|
|
64
|
+
assert.deepEqual(refreshed.map(m => m.id), ['claude-opus-9', 'claude-sonnet-9']);
|
|
65
|
+
assert.equal(refreshed[0].api, 'anthropic-messages');
|
|
66
|
+
assert.equal(refreshed[0].reasoning, true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('anthropic discovery uses x-api-key for API key credentials and paginates', async () => {
|
|
70
|
+
const calls = [];
|
|
71
|
+
const fetchImpl = async (url, options) => {
|
|
72
|
+
calls.push({ url: String(url), options });
|
|
73
|
+
if (!String(url).includes('after_id')) {
|
|
74
|
+
return jsonResponse({ data: [{ type: 'model', id: 'claude-a' }], has_more: true, last_id: 'claude-a' });
|
|
75
|
+
}
|
|
76
|
+
return jsonResponse({ data: [{ type: 'model', id: 'claude-b' }], has_more: false });
|
|
77
|
+
};
|
|
78
|
+
const config = anthropicDiscoveryProviderConfig({ fetchImpl, knownModels: noKnown });
|
|
79
|
+
const refreshed = await config.refreshModels({
|
|
80
|
+
allowNetwork: true,
|
|
81
|
+
credential: { type: 'api_key', key: 'sk-ant-key' },
|
|
82
|
+
signal: signal(),
|
|
83
|
+
});
|
|
84
|
+
assert.equal(calls.length, 2);
|
|
85
|
+
assert.equal(calls[0].options.headers['x-api-key'], 'sk-ant-key');
|
|
86
|
+
assert.equal(calls[0].options.headers.authorization, undefined);
|
|
87
|
+
assert.match(calls[1].url, /after_id=claude-a/);
|
|
88
|
+
assert.deepEqual(refreshed.map(m => m.id), ['claude-a', 'claude-b']);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('codex discovery fetches /codex/models with bearer, account id, and originator', async () => {
|
|
92
|
+
const access = fakeJwt({ 'https://api.openai.com/auth': { chatgpt_account_id: 'acct-123' } });
|
|
93
|
+
const calls = [];
|
|
94
|
+
const fetchImpl = async (url, options) => {
|
|
95
|
+
calls.push({ url: String(url), options });
|
|
96
|
+
return jsonResponse({ models: [
|
|
97
|
+
{ slug: 'gpt-9-codex', display_name: 'GPT-9 Codex', visibility: 'list', supported_reasoning_levels: ['low', 'medium', 'high', 'xhigh'] },
|
|
98
|
+
{ slug: 'gpt-9-internal', visibility: 'hide' },
|
|
99
|
+
] });
|
|
100
|
+
};
|
|
101
|
+
const config = codexDiscoveryProviderConfig({ fetchImpl, knownModels: noKnown });
|
|
102
|
+
const refreshed = await config.refreshModels({
|
|
103
|
+
allowNetwork: true,
|
|
104
|
+
credential: { type: 'oauth', access },
|
|
105
|
+
signal: signal(),
|
|
106
|
+
});
|
|
107
|
+
assert.equal(calls.length, 1);
|
|
108
|
+
assert.match(calls[0].url, /^https:\/\/chatgpt\.com\/backend-api\/codex\/models/);
|
|
109
|
+
assert.equal(calls[0].options.headers.authorization, `Bearer ${access}`);
|
|
110
|
+
assert.equal(calls[0].options.headers['chatgpt-account-id'], 'acct-123');
|
|
111
|
+
assert.equal(calls[0].options.headers.originator, 'pi');
|
|
112
|
+
assert.deepEqual(refreshed.map(m => m.id), ['gpt-9-codex']);
|
|
113
|
+
const discovered = refreshed[0];
|
|
114
|
+
assert.equal(discovered.api, 'openai-codex-responses');
|
|
115
|
+
assert.equal(discovered.baseUrl, 'https://chatgpt.com/backend-api');
|
|
116
|
+
assert.deepEqual(discovered.thinkingLevelMap, { off: null, minimal: null, low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: null });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('stored catalog metadata wins over defaults for known ids; new ids get fallbacks', async () => {
|
|
120
|
+
const fetchImpl = async () => jsonResponse({ data: [
|
|
121
|
+
{ type: 'model', id: 'claude-opus-4-5', display_name: 'Claude Opus 4.5' },
|
|
122
|
+
{ type: 'model', id: 'claude-new-thing', display_name: 'Claude New Thing' },
|
|
123
|
+
] });
|
|
124
|
+
const config = anthropicDiscoveryProviderConfig({ fetchImpl, knownModels: noKnown });
|
|
125
|
+
const stored = { models: [{
|
|
126
|
+
id: 'claude-opus-4-5', name: 'Claude Opus 4.5 (latest)', provider: 'anthropic',
|
|
127
|
+
api: 'anthropic-messages', baseUrl: 'https://api.anthropic.com',
|
|
128
|
+
contextWindow: 1_000_000, maxTokens: 128_000, reasoning: true,
|
|
129
|
+
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
|
130
|
+
}] };
|
|
131
|
+
const refreshed = await config.refreshModels({
|
|
132
|
+
allowNetwork: true,
|
|
133
|
+
credential: { type: 'oauth', access: 'tok' },
|
|
134
|
+
signal: signal(),
|
|
135
|
+
stored,
|
|
136
|
+
});
|
|
137
|
+
const known = refreshed.find(m => m.id === 'claude-opus-4-5');
|
|
138
|
+
assert.equal(known.contextWindow, 1_000_000);
|
|
139
|
+
assert.deepEqual(known.cost, { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 });
|
|
140
|
+
const unknown = refreshed.find(m => m.id === 'claude-new-thing');
|
|
141
|
+
assert.equal(unknown.contextWindow, 200_000);
|
|
142
|
+
assert.equal(unknown.maxTokens, 64_000);
|
|
143
|
+
assert.equal(unknown.name, 'Claude New Thing');
|
|
144
|
+
assert.equal(unknown.cost, undefined);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('builtin definitions keep cost and limits when no stored entry exists', async () => {
|
|
148
|
+
const fetchImpl = async () => jsonResponse({ data: [
|
|
149
|
+
{ type: 'model', id: 'claude-sonnet-4-5', display_name: 'Claude Sonnet 4.5' },
|
|
150
|
+
] });
|
|
151
|
+
const builtin = {
|
|
152
|
+
id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', provider: 'anthropic',
|
|
153
|
+
api: 'anthropic-messages', baseUrl: 'https://api.anthropic.com',
|
|
154
|
+
contextWindow: 1_000_000, maxTokens: 64_000, reasoning: true,
|
|
155
|
+
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
|
156
|
+
};
|
|
157
|
+
const config = anthropicDiscoveryProviderConfig({ fetchImpl, knownModels: async () => [builtin] });
|
|
158
|
+
const refreshed = await config.refreshModels({
|
|
159
|
+
allowNetwork: true,
|
|
160
|
+
credential: { type: 'api_key', key: 'k' },
|
|
161
|
+
signal: signal(),
|
|
162
|
+
stored: undefined,
|
|
163
|
+
});
|
|
164
|
+
const model = refreshed.find(m => m.id === 'claude-sonnet-4-5');
|
|
165
|
+
assert.equal(model.contextWindow, 1_000_000);
|
|
166
|
+
assert.deepEqual(model.cost, { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('stored catalog entries the endpoint did not return stay visible', async () => {
|
|
170
|
+
const fetchImpl = async () => jsonResponse({ data: [{ type: 'model', id: 'claude-b' }] });
|
|
171
|
+
const config = anthropicDiscoveryProviderConfig({ fetchImpl, knownModels: noKnown });
|
|
172
|
+
const refreshed = await config.refreshModels({
|
|
173
|
+
allowNetwork: true,
|
|
174
|
+
credential: { type: 'oauth', access: 'tok' },
|
|
175
|
+
signal: signal(),
|
|
176
|
+
stored: { models: [
|
|
177
|
+
{ id: 'claude-a', name: 'Claude A', provider: 'anthropic' },
|
|
178
|
+
{ id: 'claude-b', name: 'Claude B stored', provider: 'anthropic', contextWindow: 42 },
|
|
179
|
+
{ id: 'claude-c', name: 'Claude C', provider: 'anthropic' },
|
|
180
|
+
] },
|
|
181
|
+
});
|
|
182
|
+
assert.deepEqual(refreshed.map(m => m.id), ['claude-b', 'claude-a', 'claude-c']);
|
|
183
|
+
assert.equal(refreshed[0].contextWindow, 42);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('codex models the endpoint marks hidden are excluded from the stored fallback', async () => {
|
|
187
|
+
const access = fakeJwt({ 'https://api.openai.com/auth': { chatgpt_account_id: 'acct' } });
|
|
188
|
+
const fetchImpl = async () => jsonResponse({ models: [
|
|
189
|
+
{ slug: 'gpt-new', visibility: 'list' },
|
|
190
|
+
{ slug: 'gpt-old', visibility: 'hide' },
|
|
191
|
+
] });
|
|
192
|
+
const config = codexDiscoveryProviderConfig({ fetchImpl, knownModels: noKnown });
|
|
193
|
+
const refreshed = await config.refreshModels({
|
|
194
|
+
allowNetwork: true,
|
|
195
|
+
credential: { type: 'oauth', access },
|
|
196
|
+
signal: signal(),
|
|
197
|
+
stored: { models: [
|
|
198
|
+
{ id: 'gpt-old', name: 'GPT Old', provider: 'openai-codex' },
|
|
199
|
+
{ id: 'gpt-remote-extra', name: 'Remote Extra', provider: 'openai-codex' },
|
|
200
|
+
] },
|
|
201
|
+
});
|
|
202
|
+
assert.deepEqual(refreshed.map(m => m.id), ['gpt-new', 'gpt-remote-extra']);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('missing credentials and empty discovery leave the catalog untouched', async () => {
|
|
206
|
+
let called = 0;
|
|
207
|
+
const fetchImpl = async () => { called++; return jsonResponse({ data: [{ id: 'x' }] }); };
|
|
208
|
+
const config = anthropicDiscoveryProviderConfig({ fetchImpl, knownModels: noKnown });
|
|
209
|
+
assert.equal(await config.refreshModels({ allowNetwork: true, signal: signal() }), undefined);
|
|
210
|
+
assert.equal(await config.refreshModels({ allowNetwork: true, credential: { type: 'other' }, signal: signal() }), undefined);
|
|
211
|
+
const empty = anthropicDiscoveryProviderConfig({ fetchImpl: async () => jsonResponse({ data: [] }), knownModels: noKnown });
|
|
212
|
+
assert.equal(await empty.refreshModels({ allowNetwork: true, credential: { type: 'api_key', key: 'k' }, signal: signal() }), undefined);
|
|
213
|
+
assert.equal(called, 0);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('offline refresh restores the cached discovery for the same credential', async () => {
|
|
217
|
+
const cache = memoryCache();
|
|
218
|
+
const fetchImpl = async () => jsonResponse({ data: [
|
|
219
|
+
{ type: 'model', id: 'claude-new-9', display_name: 'Claude New 9' },
|
|
220
|
+
] });
|
|
221
|
+
const config = anthropicDiscoveryProviderConfig({ fetchImpl, knownModels: noKnown, cache });
|
|
222
|
+
const credential = { type: 'oauth', access: 'tok', refresh: 'rt-1' };
|
|
223
|
+
await config.refreshModels({ allowNetwork: true, credential, signal: signal() });
|
|
224
|
+
assert.equal(cache.entries.get(ANTHROPIC_PROVIDER_ID).credential, credentialKey(credential));
|
|
225
|
+
|
|
226
|
+
const offline = await config.refreshModels({ allowNetwork: false, credential, signal: signal() });
|
|
227
|
+
assert.deepEqual(offline.map(m => m.id), ['claude-new-9']);
|
|
228
|
+
assert.equal(offline[0].api, 'anthropic-messages');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('cached discovery is not restored for a different credential once migration expires', async () => {
|
|
232
|
+
const cache = memoryCache();
|
|
233
|
+
const fetchImpl = async () => jsonResponse({ data: [{ type: 'model', id: 'claude-new-9' }] });
|
|
234
|
+
const config = anthropicDiscoveryProviderConfig({ fetchImpl, knownModels: noKnown, cache, cacheGraceMs: -1 });
|
|
235
|
+
await config.refreshModels({ allowNetwork: true, credential: { type: 'api_key', key: 'sk-one' }, signal: signal() });
|
|
236
|
+
assert.equal(await config.refreshModels({ allowNetwork: false, credential: { type: 'api_key', key: 'sk-two' }, signal: signal() }), undefined);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('offline restore migrates to a rotated refresh token for a bounded window', async () => {
|
|
240
|
+
const cache = memoryCache();
|
|
241
|
+
const fetchImpl = async () => jsonResponse({ data: [{ type: 'model', id: 'claude-new-9' }] });
|
|
242
|
+
const config = anthropicDiscoveryProviderConfig({ fetchImpl, knownModels: noKnown, cache });
|
|
243
|
+
const before = { type: 'oauth', access: 'tok-1', refresh: 'rt-1' };
|
|
244
|
+
const rotated = { type: 'oauth', access: 'tok-2', refresh: 'rt-2' };
|
|
245
|
+
assert.notEqual(credentialKey(rotated), credentialKey(before));
|
|
246
|
+
await config.refreshModels({ allowNetwork: true, credential: before, signal: signal() });
|
|
247
|
+
const offline = await config.refreshModels({ allowNetwork: false, credential: rotated, signal: signal() });
|
|
248
|
+
assert.deepEqual(offline.map(m => m.id), ['claude-new-9']);
|
|
249
|
+
assert.equal(offline[0].api, 'anthropic-messages');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('a discovery under a rotated credential demotes the previous epoch and keeps it restorable', async () => {
|
|
253
|
+
const cache = memoryCache();
|
|
254
|
+
const fetchImpl = async () => jsonResponse({ data: [{ type: 'model', id: 'claude-m' }] });
|
|
255
|
+
const config = anthropicDiscoveryProviderConfig({ fetchImpl, knownModels: noKnown, cache });
|
|
256
|
+
const first = { type: 'oauth', access: 'tok-1', refresh: 'rt-1' };
|
|
257
|
+
const second = { type: 'oauth', access: 'tok-2', refresh: 'rt-2' };
|
|
258
|
+
await config.refreshModels({ allowNetwork: true, credential: first, signal: signal() });
|
|
259
|
+
await config.refreshModels({ allowNetwork: true, credential: second, signal: signal() });
|
|
260
|
+
const entry = cache.entries.get(ANTHROPIC_PROVIDER_ID);
|
|
261
|
+
assert.equal(entry.credential, credentialKey(second));
|
|
262
|
+
assert.equal(entry.previous.credential, credentialKey(first));
|
|
263
|
+
assert.equal(entry.previous.discovered[0].id, 'claude-m');
|
|
264
|
+
// A same-credential re-discovery keeps the existing lineage.
|
|
265
|
+
await config.refreshModels({ allowNetwork: true, credential: second, signal: signal() });
|
|
266
|
+
assert.equal(cache.entries.get(ANTHROPIC_PROVIDER_ID).previous.credential, credentialKey(first));
|
|
267
|
+
// The demoted epoch still restores offline under the previous credential.
|
|
268
|
+
const restored = await config.refreshModels({ allowNetwork: false, credential: first, signal: signal() });
|
|
269
|
+
assert.deepEqual(restored.map(m => m.id), ['claude-m']);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('matchCachedDiscovery matches exact, demoted, migrated, and expired epochs', () => {
|
|
273
|
+
const now = 1_000_000_000;
|
|
274
|
+
const day = 24 * 60 * 60 * 1000;
|
|
275
|
+
const graceMs = day;
|
|
276
|
+
const epoch = { credential: 'k1', checkedAt: now - 1000, discovered: [{ id: 'm' }], hiddenIds: [] };
|
|
277
|
+
assert.equal(matchCachedDiscovery(epoch, 'k1', { now, graceMs }), epoch);
|
|
278
|
+
// Exact matches are not bounded by the window.
|
|
279
|
+
const stale = { ...epoch, checkedAt: now - 30 * day };
|
|
280
|
+
assert.equal(matchCachedDiscovery(stale, 'k1', { now, graceMs }), stale);
|
|
281
|
+
// Credential changed without a discovery in between: migration fallback.
|
|
282
|
+
const migrated = { ...epoch, credential: 'k2' };
|
|
283
|
+
assert.equal(matchCachedDiscovery(migrated, 'k1', { now, graceMs }), migrated);
|
|
284
|
+
const expired = { ...epoch, credential: 'k2', checkedAt: now - day - 1 };
|
|
285
|
+
assert.equal(matchCachedDiscovery(expired, 'k1', { now, graceMs }), null);
|
|
286
|
+
// Demoted lineage is restorable under its own credential within the window.
|
|
287
|
+
const lineage = { ...epoch, credential: 'k3', previous: epoch };
|
|
288
|
+
assert.equal(matchCachedDiscovery(lineage, 'k3', { now, graceMs }), lineage);
|
|
289
|
+
assert.equal(matchCachedDiscovery(lineage, 'k1', { now, graceMs }), epoch);
|
|
290
|
+
assert.equal(matchCachedDiscovery(lineage, 'k1', { now, graceMs: -1 }), null);
|
|
291
|
+
// Malformed entries and credentials never match.
|
|
292
|
+
assert.equal(matchCachedDiscovery(null, 'k1', { now, graceMs }), null);
|
|
293
|
+
assert.equal(matchCachedDiscovery({}, 'k1', { now, graceMs }), null);
|
|
294
|
+
assert.equal(matchCachedDiscovery({ credential: 'k1' }, 'k1', { now, graceMs }), null);
|
|
295
|
+
assert.equal(matchCachedDiscovery({ credential: 'k1', discovered: [] }, 'k1', { now, graceMs }), null);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('codex discovery requires an OAuth credential', async () => {
|
|
299
|
+
let called = 0;
|
|
300
|
+
const fetchImpl = async () => { called++; return jsonResponse({ models: [] }); };
|
|
301
|
+
const config = codexDiscoveryProviderConfig({ fetchImpl, knownModels: noKnown });
|
|
302
|
+
const refreshed = await config.refreshModels({
|
|
303
|
+
allowNetwork: true,
|
|
304
|
+
credential: { type: 'api_key', key: 'sk-not-a-subscription' },
|
|
305
|
+
signal: signal(),
|
|
306
|
+
});
|
|
307
|
+
assert.equal(refreshed, undefined);
|
|
308
|
+
assert.equal(called, 0);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('parsers dedupe, skip non-model entries, and tolerate odd shapes', () => {
|
|
312
|
+
assert.deepEqual(parseAnthropicModels({ data: [
|
|
313
|
+
{ type: 'model', id: 'claude-a', display_name: 'A' },
|
|
314
|
+
{ type: 'model', id: 'claude-a' },
|
|
315
|
+
{ type: 'other', id: 'not-a-model' },
|
|
316
|
+
{ id: 'claude-b' },
|
|
317
|
+
{ type: 'model' },
|
|
318
|
+
null,
|
|
319
|
+
] }).map(m => m.id), ['claude-a', 'claude-b']);
|
|
320
|
+
const parsed = parseCodexModels({ models: [
|
|
321
|
+
{ slug: 'gpt-a', display_name: 'A' },
|
|
322
|
+
{ slug: 'gpt-a' },
|
|
323
|
+
{ id: 'gpt-b' },
|
|
324
|
+
'gpt-c',
|
|
325
|
+
{ slug: '' },
|
|
326
|
+
{ slug: 'gpt-secret', visibility: 'hide' },
|
|
327
|
+
] });
|
|
328
|
+
assert.deepEqual(parsed.models.map(m => m.id), ['gpt-a', 'gpt-b', 'gpt-c']);
|
|
329
|
+
assert.deepEqual([...parsed.hiddenIds], ['gpt-secret']);
|
|
330
|
+
assert.deepEqual(parseCodexModels({}).models, []);
|
|
331
|
+
assert.deepEqual(parseCodexModels(null).models, []);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test('codexThinkingLevelMap mirrors the endpoint levels; absent input stays unset', () => {
|
|
335
|
+
assert.equal(codexThinkingLevelMap(undefined), undefined);
|
|
336
|
+
assert.equal(codexThinkingLevelMap([]), undefined);
|
|
337
|
+
assert.deepEqual(codexThinkingLevelMap(['low', 'high']), {
|
|
338
|
+
off: null, minimal: null, low: 'low', medium: null, high: 'high', xhigh: null, max: null,
|
|
339
|
+
});
|
|
340
|
+
assert.equal(codexThinkingLevelMap(['none']).off, 'none');
|
|
341
|
+
const definition = codexModelDefinition({ id: 'gpt-x', name: 'X', reasoningLevels: ['medium'] });
|
|
342
|
+
assert.equal(definition.thinkingLevelMap.medium, 'medium');
|
|
343
|
+
const preserved = codexModelDefinition({ id: 'gpt-y', name: 'Y' }, { thinkingLevelMap: { off: null, minimal: null, low: null, medium: null, high: 'high', xhigh: 'xhigh', max: 'max' } });
|
|
344
|
+
assert.equal(preserved.thinkingLevelMap.high, 'high');
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test('decodeChatGptAccountId reads the claim and ignores malformed tokens', () => {
|
|
348
|
+
const token = fakeJwt({ 'https://api.openai.com/auth': { chatgpt_account_id: 'acct' } });
|
|
349
|
+
assert.equal(decodeChatGptAccountId(token), 'acct');
|
|
350
|
+
assert.equal(decodeChatGptAccountId('not-a-jwt'), null);
|
|
351
|
+
assert.equal(decodeChatGptAccountId(fakeJwt({})), null);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test('credentialKey stays stable per account and differs across credentials', () => {
|
|
355
|
+
assert.equal(credentialKey({ type: 'oauth', access: 'a', refresh: 'r', accountId: 'acct-1' }), 'oauth:acct-1');
|
|
356
|
+
assert.equal(credentialKey({ type: 'oauth', access: 'a', refresh: 'r' }), credentialKey({ type: 'oauth', access: 'b', refresh: 'r' }));
|
|
357
|
+
assert.notEqual(credentialKey({ type: 'api_key', key: 'k1' }), credentialKey({ type: 'api_key', key: 'k2' }));
|
|
358
|
+
assert.equal(credentialKey({ type: 'weird' }), null);
|
|
359
|
+
assert.equal(credentialKey(undefined), null);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test('mergeDiscoveredModels keeps upstream order, appends unseen stored entries, honors exclusions', () => {
|
|
363
|
+
const merged = mergeDiscoveredModels(
|
|
364
|
+
[{ id: 'a' }, { id: 'b' }, { id: 'hidden' }],
|
|
365
|
+
[{ id: 'b', x: 1 }, { id: 'c' }],
|
|
366
|
+
{ excludeIds: new Set(['hidden']) },
|
|
367
|
+
);
|
|
368
|
+
assert.deepEqual(merged.map(m => m.id), ['b', 'c', 'a']);
|
|
369
|
+
assert.equal(merged[0].x, 1);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test('fileSubscriptionCache round-trips entries per provider', async () => {
|
|
373
|
+
const dir = mkdtempSync(join(tmpdir(), 'ctg-sub-cache-'));
|
|
374
|
+
const cache = fileSubscriptionCache(dir);
|
|
375
|
+
await cache.write(ANTHROPIC_PROVIDER_ID, { credential: 'k', discovered: [{ id: 'm' }], hiddenIds: [] });
|
|
376
|
+
const persisted = JSON.parse(readFileSync(cache.file, 'utf8'));
|
|
377
|
+
assert.equal(persisted[ANTHROPIC_PROVIDER_ID].discovered[0].id, 'm');
|
|
378
|
+
assert.deepEqual(await cache.read(ANTHROPIC_PROVIDER_ID), { credential: 'k', discovered: [{ id: 'm' }], hiddenIds: [] });
|
|
379
|
+
assert.equal(await cache.read(OPENAI_CODEX_PROVIDER_ID), null);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test('fileSubscriptionCache serializes concurrent writes across cache instances', async () => {
|
|
383
|
+
const dir = mkdtempSync(join(tmpdir(), 'ctg-sub-cache-'));
|
|
384
|
+
const anthropic = fileSubscriptionCache(dir);
|
|
385
|
+
const codex = fileSubscriptionCache(dir);
|
|
386
|
+
await Promise.all([
|
|
387
|
+
anthropic.write(ANTHROPIC_PROVIDER_ID, { credential: 'k-a', discovered: [{ id: 'claude-a' }], hiddenIds: [] }),
|
|
388
|
+
codex.write(OPENAI_CODEX_PROVIDER_ID, { credential: 'k-c', discovered: [{ id: 'gpt-a' }], hiddenIds: [] }),
|
|
389
|
+
]);
|
|
390
|
+
const persisted = JSON.parse(readFileSync(anthropic.file, 'utf8'));
|
|
391
|
+
assert.equal(persisted[ANTHROPIC_PROVIDER_ID]?.credential, 'k-a');
|
|
392
|
+
assert.equal(persisted[OPENAI_CODEX_PROVIDER_ID]?.credential, 'k-c');
|
|
393
|
+
const anthropicEntry = await codex.read(ANTHROPIC_PROVIDER_ID);
|
|
394
|
+
const codexEntry = await anthropic.read(OPENAI_CODEX_PROVIDER_ID);
|
|
395
|
+
assert.deepEqual(anthropicEntry?.discovered, [{ id: 'claude-a' }]);
|
|
396
|
+
assert.deepEqual(codexEntry?.discovered, [{ id: 'gpt-a' }]);
|
|
397
|
+
// Later writes merge over the existing entries instead of replacing them.
|
|
398
|
+
await Promise.all([
|
|
399
|
+
anthropic.write(ANTHROPIC_PROVIDER_ID, { credential: 'k-a2', discovered: [{ id: 'claude-a2' }], hiddenIds: [] }),
|
|
400
|
+
codex.write(OPENAI_CODEX_PROVIDER_ID, { credential: 'k-c2', discovered: [{ id: 'gpt-a2' }], hiddenIds: [] }),
|
|
401
|
+
]);
|
|
402
|
+
const merged = JSON.parse(readFileSync(anthropic.file, 'utf8'));
|
|
403
|
+
assert.equal(merged[ANTHROPIC_PROVIDER_ID]?.credential, 'k-a2');
|
|
404
|
+
assert.equal(merged[OPENAI_CODEX_PROVIDER_ID]?.credential, 'k-c2');
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test('registerSubscriptionProviders overlays both builtin providers', () => {
|
|
408
|
+
const registrations = [];
|
|
409
|
+
registerSubscriptionProviders({ registerProvider: (id, config) => registrations.push([id, config]) });
|
|
410
|
+
assert.deepEqual(registrations.map(([id]) => id), [ANTHROPIC_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID]);
|
|
411
|
+
for (const [, config] of registrations) {
|
|
412
|
+
assert.equal(typeof config.refreshModels, 'function');
|
|
413
|
+
assert.equal(typeof config.api, 'string');
|
|
414
|
+
assert.equal(typeof config.baseUrl, 'string');
|
|
415
|
+
}
|
|
416
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const requireEngine = createRequire(import.meta.url);
|
|
7
|
+
const { createJiti } = requireEngine('jiti');
|
|
8
|
+
const jiti = createJiti(import.meta.url, {
|
|
9
|
+
alias: { '@earendil-works/pi-coding-agent': fileURLToPath(new URL('../vendor/agent/index.js', import.meta.url)) },
|
|
10
|
+
});
|
|
11
|
+
const factory = await jiti.import(fileURLToPath(new URL('../extensions/subscriptions.ts', import.meta.url)), { default: true });
|
|
12
|
+
|
|
13
|
+
test('subscriptions extension registers both overlays and refreshes them on session_start', async () => {
|
|
14
|
+
const providers = [];
|
|
15
|
+
const hooks = {};
|
|
16
|
+
factory({
|
|
17
|
+
registerProvider(id, config) { providers.push([id, config]); },
|
|
18
|
+
on(name, callback) { hooks[name] = callback; },
|
|
19
|
+
});
|
|
20
|
+
assert.deepEqual(providers.map(([id]) => id), ['anthropic', 'openai-codex']);
|
|
21
|
+
for (const [, config] of providers) assert.equal(typeof config.refreshModels, 'function');
|
|
22
|
+
|
|
23
|
+
assert.equal(typeof hooks.session_start, 'function');
|
|
24
|
+
const calls = [];
|
|
25
|
+
await hooks.session_start({}, { modelRegistry: { refresh: options => calls.push(options) } });
|
|
26
|
+
assert.deepEqual(calls, [{ providers: ['anthropic', 'openai-codex'] }]);
|
|
27
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { terminalTitleSequence } from '../lib/tui/title.mjs';
|
|
4
|
+
|
|
5
|
+
test('terminal title uses Carthagent branding and the workspace folder', () => {
|
|
6
|
+
assert.equal(terminalTitleSequence('/work/my-project'), '\x1b]0;Carthagent - my-project\x07');
|
|
7
|
+
assert.equal(terminalTitleSequence('/'), '\x1b]0;Carthagent\x07');
|
|
8
|
+
assert.equal(terminalTitleSequence('/work/café app'), '\x1b]0;Carthagent - café app\x07');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('terminal title strips control characters from folder names', () => {
|
|
12
|
+
assert.equal(terminalTitleSequence('/work/project\x07\x1b\n\r\x9c'), '\x1b]0;Carthagent - project\x07');
|
|
13
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
import textwrap
|
|
5
|
+
import unittest
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from tempfile import TemporaryDirectory
|
|
8
|
+
|
|
9
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
10
|
+
GRADER = ROOT / "scenarios" / "game" / "grade_minecraft_clone.py"
|
|
11
|
+
sys.path.insert(0, str(GRADER.parent))
|
|
12
|
+
|
|
13
|
+
from grade_minecraft_clone import nested_ursina_callbacks, playable_frame, run
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def write_file(path, body):
|
|
17
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
path.write_text(textwrap.dedent(body).lstrip())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def write_common_files(work):
|
|
22
|
+
write_file(work / "README.md", """
|
|
23
|
+
# Minecraft demo
|
|
24
|
+
|
|
25
|
+
Run with `python minecraft.py`. Use W/A/S/D to move, mouse to look,
|
|
26
|
+
number keys to select blocks, click to break/place, P to pause, and S to save.
|
|
27
|
+
""")
|
|
28
|
+
write_file(work / "tests" / "test_game.py", """
|
|
29
|
+
import unittest
|
|
30
|
+
|
|
31
|
+
class GameTests(unittest.TestCase):
|
|
32
|
+
def test_world_math(self):
|
|
33
|
+
self.assertEqual(2 + 2, 4)
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
unittest.main()
|
|
37
|
+
""")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def write_probe(path):
|
|
41
|
+
write_file(path, """
|
|
42
|
+
import json
|
|
43
|
+
import sys
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
from PIL import Image
|
|
46
|
+
|
|
47
|
+
shot = Path(sys.argv[sys.argv.index('--screenshot') + 1])
|
|
48
|
+
shot.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
image = Image.new('RGB', (96, 64), (120, 180, 220))
|
|
50
|
+
for i in range(16):
|
|
51
|
+
for x in range(i * 6, i * 6 + 6):
|
|
52
|
+
for y in range(0, 64):
|
|
53
|
+
image.putpixel((x, y), ((i * 30) % 255, (50 + i * 20) % 255, (100 + i * 10) % 255))
|
|
54
|
+
image.save(shot)
|
|
55
|
+
print('URSINA_PROBE_RESULT ' + json.dumps({'status': 'passed', 'captures': [{'sampled_colors': 16}]}))
|
|
56
|
+
""")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def write_candidate(path, marker=True, dark=False, save_world=True):
|
|
60
|
+
marker_line = """
|
|
61
|
+
print('GAME_SMOKE_RESULT ' + json.dumps({
|
|
62
|
+
'spawn_safe': True,
|
|
63
|
+
'moved': True,
|
|
64
|
+
'jumped': True,
|
|
65
|
+
'looked': True,
|
|
66
|
+
'selected_block_changed': True,
|
|
67
|
+
'break_changed_world': True,
|
|
68
|
+
'place_changed_world': True,
|
|
69
|
+
'pause_toggled': True,
|
|
70
|
+
'save_roundtrip': True,
|
|
71
|
+
'block_type_count': 8,
|
|
72
|
+
'world_blocks_before': 40,
|
|
73
|
+
'world_blocks_after_break': 39,
|
|
74
|
+
'world_blocks_after_place': 40,
|
|
75
|
+
}))
|
|
76
|
+
""" if marker else ""
|
|
77
|
+
image_body = """
|
|
78
|
+
image = Image.new('RGB', (96, 64), (0, 0, 0))
|
|
79
|
+
""" if dark else """
|
|
80
|
+
image = Image.new('RGB', (96, 64), (120, 180, 220))
|
|
81
|
+
for i in range(16):
|
|
82
|
+
for x in range(i * 6, i * 6 + 6):
|
|
83
|
+
for y in range(0, 64):
|
|
84
|
+
image.putpixel((x, y), ((i * 30) % 255, (50 + i * 20) % 255, (100 + i * 10) % 255))
|
|
85
|
+
"""
|
|
86
|
+
world = "{str((i, 0, 0)): 'stone' for i in range(40)}" if save_world else "{}"
|
|
87
|
+
body = f"""import argparse
|
|
88
|
+
import json
|
|
89
|
+
from pathlib import Path
|
|
90
|
+
from PIL import Image
|
|
91
|
+
|
|
92
|
+
def main():
|
|
93
|
+
parser = argparse.ArgumentParser()
|
|
94
|
+
parser.add_argument('--smoke', action='store_true')
|
|
95
|
+
parser.add_argument('--screenshot', default='artifacts/smoke.png')
|
|
96
|
+
parser.add_argument('--save', default='saves/world.json')
|
|
97
|
+
args = parser.parse_args()
|
|
98
|
+
paused = False
|
|
99
|
+
if not args.smoke:
|
|
100
|
+
print('launch minecraft game')
|
|
101
|
+
return
|
|
102
|
+
shot = Path(args.screenshot)
|
|
103
|
+
shot.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
{textwrap.indent(textwrap.dedent(image_body).strip(), ' ')}
|
|
105
|
+
image.save(shot)
|
|
106
|
+
save = Path(args.save)
|
|
107
|
+
save.parent.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
save.write_text(json.dumps({{'world': {world}, 'selected_block': 1}}))
|
|
109
|
+
{textwrap.indent(textwrap.dedent(marker_line).strip(), ' ')}
|
|
110
|
+
|
|
111
|
+
if __name__ == '__main__':
|
|
112
|
+
main()
|
|
113
|
+
"""
|
|
114
|
+
write_file(path, body)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class GradeMinecraftCloneTests(unittest.TestCase):
|
|
118
|
+
def run_grade(self, work):
|
|
119
|
+
probe = work / "probe.py"
|
|
120
|
+
write_probe(probe)
|
|
121
|
+
return subprocess.run(
|
|
122
|
+
[sys.executable, str(GRADER), str(work), "--probe", str(probe), "--skip-interactive"],
|
|
123
|
+
text=True,
|
|
124
|
+
stdout=subprocess.PIPE,
|
|
125
|
+
stderr=subprocess.STDOUT,
|
|
126
|
+
timeout=15,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def test_accepts_gameplay_smoke_protocol(self):
|
|
130
|
+
with TemporaryDirectory() as tmp:
|
|
131
|
+
work = Path(tmp)
|
|
132
|
+
write_common_files(work)
|
|
133
|
+
write_candidate(work / "minecraft.py")
|
|
134
|
+
result = self.run_grade(work)
|
|
135
|
+
self.assertEqual(result.returncode, 0, result.stdout)
|
|
136
|
+
self.assertIn("GAME_GRADE_RESULT", result.stdout)
|
|
137
|
+
|
|
138
|
+
def test_rejects_renderer_only_smoke(self):
|
|
139
|
+
with TemporaryDirectory() as tmp:
|
|
140
|
+
work = Path(tmp)
|
|
141
|
+
write_common_files(work)
|
|
142
|
+
write_candidate(work / "minecraft.py", marker=False)
|
|
143
|
+
result = self.run_grade(work)
|
|
144
|
+
self.assertNotEqual(result.returncode, 0)
|
|
145
|
+
self.assertIn("Missing GAME_SMOKE_RESULT", result.stdout)
|
|
146
|
+
|
|
147
|
+
def test_rejects_pause_overlay_as_smoke_evidence(self):
|
|
148
|
+
with TemporaryDirectory() as tmp:
|
|
149
|
+
work = Path(tmp)
|
|
150
|
+
write_common_files(work)
|
|
151
|
+
write_candidate(work / "minecraft.py", dark=True)
|
|
152
|
+
result = self.run_grade(work)
|
|
153
|
+
self.assertNotEqual(result.returncode, 0)
|
|
154
|
+
self.assertIn("mostly dark", result.stdout)
|
|
155
|
+
|
|
156
|
+
def test_rejects_empty_saved_world(self):
|
|
157
|
+
with TemporaryDirectory() as tmp:
|
|
158
|
+
work = Path(tmp)
|
|
159
|
+
write_common_files(work)
|
|
160
|
+
write_candidate(work / "minecraft.py", save_world=False)
|
|
161
|
+
result = self.run_grade(work)
|
|
162
|
+
self.assertNotEqual(result.returncode, 0)
|
|
163
|
+
self.assertIn("meaningful world progress", result.stdout)
|
|
164
|
+
|
|
165
|
+
def test_command_timeout_is_reported_as_a_failed_result(self):
|
|
166
|
+
with TemporaryDirectory() as tmp:
|
|
167
|
+
result = run(
|
|
168
|
+
[sys.executable, "-c", "import time; time.sleep(1)"],
|
|
169
|
+
Path(tmp),
|
|
170
|
+
timeout=0.01,
|
|
171
|
+
)
|
|
172
|
+
self.assertEqual(result.returncode, 124)
|
|
173
|
+
self.assertIn("Timed out", result.stdout)
|
|
174
|
+
|
|
175
|
+
def test_playable_frame_rejects_empty_sky(self):
|
|
176
|
+
with TemporaryDirectory() as tmp:
|
|
177
|
+
shot = Path(tmp) / "sky.png"
|
|
178
|
+
from PIL import Image
|
|
179
|
+
Image.new("RGB", (96, 64), (120, 190, 220)).save(shot)
|
|
180
|
+
self.assertFalse(playable_frame(shot))
|
|
181
|
+
|
|
182
|
+
def test_reports_nested_ursina_callbacks(self):
|
|
183
|
+
with TemporaryDirectory() as tmp:
|
|
184
|
+
entry = Path(tmp) / "minecraft.py"
|
|
185
|
+
write_file(entry, """
|
|
186
|
+
def main():
|
|
187
|
+
def input(key):
|
|
188
|
+
return key
|
|
189
|
+
def update():
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
def module_helper():
|
|
193
|
+
return None
|
|
194
|
+
""")
|
|
195
|
+
self.assertEqual(nested_ursina_callbacks(entry), ["input", "update"])
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
if __name__ == "__main__":
|
|
199
|
+
unittest.main()
|