@desplega.ai/agent-swarm 2.7.4 → 2.7.5
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/README.md +1 -1
- package/dist/acp-adapter-g3ads55s.js +15 -0
- package/dist/{actions-gfrr9vb7.js → actions-zwfykqqk.js} +18 -18
- package/dist/{amp-adapter-0s58gm46.js → amp-adapter-z3s0ssdt.js} +3 -3
- package/dist/{app-stvq692g.js → app-vmp4fgep.js} +3 -3
- package/dist/{assistant-5s37fr9n.js → assistant-vwde653f.js} +28 -21
- package/dist/{boot-reembed-yzy1841n.js → boot-reembed-375t7tbk.js} +1 -1
- package/dist/{boot-reembed-cv82gz7y.js → boot-reembed-gq9rcpnj.js} +1 -1
- package/dist/{boot-scrub-sweep-8k4q95ap.js → boot-scrub-sweep-nd938kyf.js} +4 -4
- package/dist/{boot-seeds-ftt9htah.js → boot-seeds-aykx51v3.js} +4 -4
- package/dist/{boxd-515v1t8z.js → boxd-k1j26g3h.js} +1 -1
- package/dist/{claude-adapter-t00wrzeh.js → claude-adapter-8j4hm099.js} +7 -7
- package/dist/{claude-managed-adapter-g6vmjccb.js → claude-managed-adapter-dvs2xw5n.js} +2 -2
- package/dist/{claude-managed-setup-afpa82f7.js → claude-managed-setup-0wyynpn1.js} +3 -3
- package/dist/{claude-sdk-session-vj0c2azx.js → claude-sdk-session-q6wj5hmy.js} +9 -9
- package/dist/{cli-wndv9p7g.js → cli-0731hvbw.js} +6 -6
- package/dist/{cli-d2pzqej1.js → cli-0bxqnvq9.js} +719 -7
- package/dist/{cli-jkt52mgt.js → cli-0gey5fzy.js} +2 -2
- package/dist/{cli-1yekbyqm.js → cli-1338796d.js} +205 -48
- package/dist/{cli-1s1dqn1j.js → cli-1ayk1eas.js} +171 -123
- package/dist/{cli-a4btqqft.js → cli-1b7btd38.js} +1 -1
- package/dist/{cli-a2jw6ee5.js → cli-1p89xny0.js} +1 -1
- package/dist/{cli-v726afdm.js → cli-2pnswgh8.js} +1 -1
- package/dist/{cli-kvtc7aez.js → cli-3h4nhjn8.js} +42 -14
- package/dist/{cli-655xm9ms.js → cli-583frbyr.js} +1 -1
- package/dist/{cli-4r12t6w6.js → cli-5c4hjzne.js} +101 -4
- package/dist/{cli-az0bpf8p.js → cli-5pag6rmn.js} +3 -3
- package/dist/{cli-36g23z21.js → cli-6s7vdcta.js} +4 -4
- package/dist/{cli-rtk9g7av.js → cli-73jzwksr.js} +6 -2
- package/dist/{cli-jg0nfda6.js → cli-75gkpjvw.js} +52 -21
- package/dist/{cli-a81qevyg.js → cli-7a42nfy0.js} +20 -36
- package/dist/{cli-0sbkss7h.js → cli-7wcyk2s5.js} +2 -2
- package/dist/{cli-41z8xwj0.js → cli-7wspm5gm.js} +14 -4
- package/dist/{cli-byqfbnjw.js → cli-82ae98pz.js} +9 -9
- package/dist/{cli-bq2pcphn.js → cli-8hs41zy2.js} +1 -1
- package/dist/{cli-vqgnyh76.js → cli-98rw5dt5.js} +15 -3
- package/dist/{cli-9cctx1tn.js → cli-a22h291q.js} +1 -1
- package/dist/{cli-j4kt1mpb.js → cli-a47xy8eq.js} +3 -3
- package/dist/{cli-ycm6180s.js → cli-a7v854rf.js} +5 -3
- package/dist/{cli-98pke4vk.js → cli-ae7mrdf9.js} +2 -2
- package/dist/{cli-ahd90bwt.js → cli-afrrppsw.js} +6 -6
- package/dist/{cli-8yy525ad.js → cli-bhjt02p8.js} +2 -2
- package/dist/{cli-v1yw3wha.js → cli-bnz7a7qf.js} +4 -4
- package/dist/{cli-j4kw3rsx.js → cli-cm5yqn35.js} +18 -0
- package/dist/{cli-h55y2bjc.js → cli-dhar9qay.js} +2 -2
- package/dist/{cli-5gr1g711.js → cli-fqzx7gwa.js} +2 -2
- package/dist/{cli-tzrpv5sc.js → cli-ftyxyspd.js} +1 -1
- package/dist/cli-fw2w0kva.js +47 -0
- package/dist/{cli-2dfbv8qr.js → cli-fy6x31ps.js} +1 -0
- package/dist/{cli-mvckssyq.js → cli-g61qxmge.js} +5 -5
- package/dist/{cli-aq58fk5m.js → cli-g83s9ewh.js} +7 -1
- package/dist/{cli-224epwwf.js → cli-hn9hxmz9.js} +3 -3
- package/dist/{cli-5hjced2v.js → cli-j3jvnpe8.js} +6 -6
- package/dist/{cli-dae4sbgy.js → cli-jm2vfpfd.js} +2 -2
- package/dist/{cli-zn15c46z.js → cli-mjzynrse.js} +1 -1
- package/dist/{cli-a1fhj4n9.js → cli-mmwm7zbf.js} +2 -2
- package/dist/{cli-cq1pf336.js → cli-mry0qzym.js} +5 -5
- package/dist/{cli-bwty35gx.js → cli-mthwvjre.js} +1 -1
- package/dist/{cli-s0exq74r.js → cli-nmbmy64z.js} +3 -3
- package/dist/{cli-wcmnan2g.js → cli-nn4d8dne.js} +5 -5
- package/dist/{cli-jg4p5t36.js → cli-nzxr556s.js} +121 -51
- package/dist/{cli-bcz074zx.js → cli-p599f1qm.js} +1 -1
- package/dist/{cli-k76tgq03.js → cli-pywf3eh5.js} +2 -2
- package/dist/{cli-wk9md5px.js → cli-qxnf8z2z.js} +2 -2
- package/dist/{cli-je8yeyc5.js → cli-rhw40jgb.js} +5 -5
- package/dist/{cli-avgcz4ra.js → cli-rm6hqj3z.js} +406 -35
- package/dist/{cli-0fgr1w9b.js → cli-rpm2sgv0.js} +2 -2
- package/dist/{cli-t282jgab.js → cli-rqkxrmgw.js} +12 -12
- package/dist/{cli-2qpvmmkw.js → cli-s0qnd4wv.js} +1 -1
- package/dist/{cli-pwyevs29.js → cli-spknvxbq.js} +22 -21
- package/dist/{cli-9y354g3j.js → cli-srye3vdt.js} +1 -1
- package/dist/{cli-pe09p6dv.js → cli-tqgg40ek.js} +1 -1
- package/dist/{cli-f255yaqn.js → cli-vbyfwh92.js} +3 -3
- package/dist/{cli-gcbdjt47.js → cli-vfym7wmz.js} +6 -6
- package/dist/{cli-edmm9d9y.js → cli-vqypxzh4.js} +7 -7
- package/dist/{cli-1gm8jcyr.js → cli-wvaawacd.js} +13 -13
- package/dist/{cli-eny54typ.js → cli-wx3rhnm0.js} +1 -1
- package/dist/{cli-b1afgtj3.js → cli-xbkdm710.js} +3 -3
- package/dist/{cli-mc97xy2s.js → cli-xtjhfmpc.js} +16 -4
- package/dist/{cli-eqx6k993.js → cli-y24sc29k.js} +25 -13
- package/dist/{cli-ktmvb20s.js → cli-z6jkx23q.js} +12 -12
- package/dist/{cli-7n5sbw0z.js → cli-z8eh3ex8.js} +5 -5
- package/dist/cli-zh3fp8v2.js +151 -0
- package/dist/{cli-13jvmew3.js → cli-zppwg9p9.js} +7 -7
- package/dist/cli.js +18 -18
- package/dist/{codex-adapter-g0gctw29.js → codex-adapter-q6k3gnyg.js} +5 -5
- package/dist/{codex-hook-vrzz2xhr.js → codex-hook-9efm2x1m.js} +1 -1
- package/dist/{codex-login-0gg46ygn.js → codex-login-y0pmff1c.js} +1 -1
- package/dist/{codex-session-runner-dr2cxe1c.js → codex-session-runner-3mwmk3d9.js} +6 -6
- package/dist/{commands-1p07cvff.js → commands-evrenjn9.js} +2 -2
- package/dist/{controller-z0s7sv6r.js → controller-xjp8vswh.js} +4 -4
- package/dist/{controllers-connect-x9kvvhyk.js → controllers-connect-pp5jnxvc.js} +20 -20
- package/dist/{cursor-adapter-m5b98pvk.js → cursor-adapter-yf47w4t0.js} +2 -2
- package/dist/{db-rsh04kh3.js → db-rgfsh91f.js} +2 -2
- package/dist/{db-capk2raj.js → db-zxpw6aht.js} +3 -3
- package/dist/{devin-adapter-yxm87fe9.js → devin-adapter-r9phbnc9.js} +1 -1
- package/dist/{dispatcher-304q6fwa.js → dispatcher-smasgws7.js} +17 -17
- package/dist/{docker-7rxt0rry.js → docker-rrjhaqt9.js} +2 -2
- package/dist/{dsh-adapter-btewem0y.js → dsh-adapter-ftyy8dnp.js} +2 -2
- package/dist/{e2b-renc0k8q.js → e2b-qcaj675y.js} +2 -2
- package/dist/{e2b-7fc9b9bd.js → e2b-w1p0bnpm.js} +2 -2
- package/dist/{easter-egg-3d-fm1p3n4d.js → easter-egg-3d-yyts7p16.js} +1 -1
- package/dist/{embedding-space-s68v49ct.js → embedding-space-1z0mbg95.js} +6 -6
- package/dist/{embeddings-4ms3604w.js → embeddings-ygeg7tm7.js} +1 -1
- package/dist/{event-bus-dfjx2qdh.js → event-bus-g0ka3pbw.js} +1 -1
- package/dist/{execute-mcdgccjs.js → execute-14qhqp4d.js} +1 -1
- package/dist/{external-channel-9dxwfztn.js → external-channel-ttgrs3sx.js} +2 -2
- package/dist/fx-adapter-gt6pncj2.js +18 -0
- package/dist/{grok-adapter-05ecv4h0.js → grok-adapter-sm4m5k4g.js} +8 -8
- package/dist/handlers-vwmba1m2.js +31 -0
- package/dist/{heartbeat-3n94heh3.js → heartbeat-qahz4p62.js} +20 -20
- package/dist/{hook-zxwje86c.js → hook-4fd5wq5w.js} +4 -4
- package/dist/{hook-aadrn69x.js → hook-kj3d398m.js} +6 -6
- package/dist/{http-xzxk9mst.js → http-1ted0j1k.js} +100 -64
- package/dist/index-5r71rf98.js +32 -0
- package/dist/{index-d34f6r87.js → index-6rq3r5zb.js} +1 -1
- package/dist/{index-9j4zwcht.js → index-7v51awbn.js} +158 -672
- package/dist/index-anmwxs87.js +29 -0
- package/dist/{index-qeb82z37.js → index-q4b917zs.js} +1 -1
- package/dist/index-spp0ra5c.js +36 -0
- package/dist/{keepalive-w4aakbyk.js → keepalive-6cf311t5.js} +4 -4
- package/dist/lead-fjy418cw.js +69 -0
- package/dist/{maintenance-hbptpk6p.js → maintenance-a4bm1bmt.js} +1 -1
- package/dist/{oauth-refresh-sweep-217semrv.js → oauth-refresh-sweep-3h0dfw1k.js} +3 -3
- package/dist/{onboard-vz2s5df3.js → onboard-myn5hraa.js} +3 -3
- package/dist/{opencode-adapter-gtfd7d7v.js → opencode-adapter-p2nkb5am.js} +5 -5
- package/dist/{otel-impl-aefave37.js → otel-impl-a1a0ef6b.js} +2 -2
- package/dist/{pi-mono-adapter-f8h5yseh.js → pi-mono-adapter-rzmpb72a.js} +117 -23
- package/dist/{pricing-refresh-m1yhh9ya.js → pricing-refresh-83drrnxj.js} +3 -3
- package/dist/{rbac-roles-svj72cdf.js → rbac-roles-0fb6e7pj.js} +2 -2
- package/dist/{rbac-roles-rrw8twdz.js → rbac-roles-s8y6ecb5.js} +1 -1
- package/dist/{render-v2-amwbnfxh.js → render-v2-twmbgrz4.js} +3 -3
- package/dist/{resume-9cd6wf6z.js → resume-zfqc36xj.js} +7 -7
- package/dist/{runtime-fp4vrx7m.js → runtime-qwggdpqr.js} +1 -1
- package/dist/{session-access-21t6yjyg.js → session-access-sc9pn9da.js} +4 -4
- package/dist/{session-source-backfill-pxbzcsc6.js → session-source-backfill-qh07xr8g.js} +3 -3
- package/dist/{setup-4592tqz4.js → setup-4fr0m7a1.js} +1 -1
- package/dist/{virtual-modules-gzfnpmnz.js → virtual-modules-fh4ha15b.js} +2 -2
- package/dist/{work-objects-t9tcf174.js → work-objects-0wr8zxct.js} +20 -20
- package/dist/worker-0c5tyzqe.js +69 -0
- package/dist/{worker-follow-up-q32jh4yk.js → worker-follow-up-ktyyazyp.js} +17 -17
- package/dist/{x-b6wxntdt.js → x-vmmye1kb.js} +2 -2
- package/openapi.json +97 -32
- package/package.json +1 -1
- package/src/be/db/session-summaries.ts +8 -2
- package/src/be/db/session-tree.ts +174 -61
- package/src/be/db/session-visibility.ts +5 -4
- package/src/be/db.ts +8 -4
- package/src/be/migrations/259_session_summaries_open_tasks.sql +111 -0
- package/src/be/model-validation.ts +5 -1
- package/src/be/pricing-normalize.ts +7 -2
- package/src/be/scripts/typecheck.ts +1 -0
- package/src/be/seed/schedules-seeder.ts +135 -9
- package/src/be/seed/workflows-seeder.ts +65 -1
- package/src/be/seed-pricing.ts +23 -0
- package/src/be/steering.ts +50 -3
- package/src/be/swarm-config-guard.ts +1 -0
- package/src/commands/provider-credentials.ts +29 -2
- package/src/commands/runner.ts +86 -5
- package/src/extensions/contract-types.generated.ts +1 -1
- package/src/extensions/contract.ts +27 -1
- package/src/extensions/dispatcher.ts +4 -0
- package/src/http/agents.ts +1 -0
- package/src/http/mcp-bridge.ts +4 -0
- package/src/integrations/chat-sdk/inbound.ts +15 -5
- package/src/integrations/chat-sdk/steering.ts +3 -1
- package/src/kv-reserved-namespaces.ts +2 -0
- package/src/prompts/base-prompt.ts +4 -1
- package/src/prompts/session-templates.ts +26 -0
- package/src/providers/acp-adapter.ts +190 -26
- package/src/providers/acp-targets.ts +306 -6
- package/src/providers/amp-adapter.ts +1 -1
- package/src/providers/codex-adapter.ts +9 -44
- package/src/providers/codex-oauth/flow.ts +24 -3
- package/src/providers/codex-oauth/storage.ts +56 -17
- package/src/providers/codex-oauth/subscription.ts +67 -0
- package/src/providers/fx-adapter.ts +211 -0
- package/src/providers/index.ts +5 -1
- package/src/providers/mcp-loopback-proxy.ts +94 -0
- package/src/providers/pi-mono-adapter.ts +98 -13
- package/src/providers/pricing-sources.md +8 -0
- package/src/providers/reasoning-effort.ts +17 -1
- package/src/providers/types.ts +2 -1
- package/src/scheduler/scheduler.ts +36 -3
- package/src/scripts-runtime/sdk-allowlist.ts +1 -0
- package/src/scripts-runtime/types/stdlib.d.ts +5 -0
- package/src/scripts-runtime/types/swarm-extension.d.ts +33 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +5 -0
- package/src/server.ts +7 -0
- package/src/slack/assistant.ts +17 -1
- package/src/slack/handlers.ts +18 -5
- package/src/slack/left-threads.ts +65 -0
- package/src/slack/steering.ts +3 -0
- package/src/slack/thread-buffer.ts +40 -16
- package/src/tests/acp-adapter.test.ts +79 -0
- package/src/tests/base-prompt.test.ts +12 -0
- package/src/tests/chat-sdk-inbound.test.ts +30 -0
- package/src/tests/chatgpt-subscription-model-groups.test.ts +62 -0
- package/src/tests/chatgpt-subscription-pi.test.ts +170 -0
- package/src/tests/chatgpt-subscription-session-bench.test.ts +198 -0
- package/src/tests/codex-oauth-credential-info.test.ts +40 -0
- package/src/tests/codex-oauth-refresh-egress.test.ts +194 -0
- package/src/tests/context-window.test.ts +7 -0
- package/src/tests/explicit-model-validation.test.ts +99 -0
- package/src/tests/extensions-run-context.test.ts +11 -0
- package/src/tests/extensions-steering.test.ts +353 -0
- package/src/tests/extensions-validate.test.ts +5 -0
- package/src/tests/fixtures/extensions/steering-policy/hooks.ts +39 -0
- package/src/tests/fixtures/extensions/steering-policy/manifest.json +7 -0
- package/src/tests/fixtures/fake-fx.ts +167 -0
- package/src/tests/fixtures/fx/acp-responses-recorded.json +157 -0
- package/src/tests/fixtures/fx/acp-updates-recorded.jsonl +15 -0
- package/src/tests/fx-adapter.test.ts +771 -0
- package/src/tests/grok-adapter.test.ts +36 -0
- package/src/tests/mcp-loopback-proxy.test.ts +193 -0
- package/src/tests/model-tier-resolution.test.ts +3 -3
- package/src/tests/pricing-refresh.test.ts +5 -5
- package/src/tests/prompt-template-session.test.ts +5 -3
- package/src/tests/provider-registration.test.ts +2 -1
- package/src/tests/query-audit-plans.test.ts +3 -2
- package/src/tests/script-connections.test.ts +2 -0
- package/src/tests/seed-model-profiles.test.ts +2 -2
- package/src/tests/seed-schedules.test.ts +138 -0
- package/src/tests/session-cost-recompute-unit.test.ts +39 -0
- package/src/tests/session-costs-golden.test.ts +26 -0
- package/src/tests/session-summaries-plans.test.ts +120 -0
- package/src/tests/session-summaries.test.ts +72 -12
- package/src/tests/sessions-tree.test.ts +11 -3
- package/src/tests/slack-leave-thread.test.ts +431 -0
- package/src/tests/tool-annotations.test.ts +2 -1
- package/src/tests/workflow-engine-v2.test.ts +42 -0
- package/src/tests/workflow-script-principal.test.ts +207 -7
- package/src/tools/slack-leave-thread.ts +104 -0
- package/src/tools/steer-task.ts +3 -2
- package/src/tools/tool-config.ts +2 -1
- package/src/types.ts +19 -0
- package/src/utils/codex-auth-failure.ts +30 -0
- package/src/utils/context-window.ts +4 -1
- package/src/utils/credentials.ts +7 -0
- package/src/utils/fx-models.ts +43 -0
- package/src/workflows/cooldown.ts +3 -2
- package/src/workflows/engine.ts +24 -9
- package/templates/schedules/dreaming-daily/config.json +1 -1
- package/templates/schedules/dreaming-daily/content.md +6 -14
- package/templates/skills/swarm-docs/content.md +1 -0
- package/templates/workflows/dreaming/content.md +5 -1
- package/dist/acp-adapter-96dc64m2.js +0 -15
- package/dist/handlers-jg7v74ty.js +0 -31
- package/dist/index-8c1zbz59.js +0 -29
- package/dist/index-8k8tmfxd.js +0 -32
- package/dist/index-hq617xdj.js +0 -36
- package/dist/lead-567kw7x3.js +0 -68
- package/dist/worker-fjdqra6q.js +0 -68
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatGPT subscription credential for a pi session that runs an
|
|
3
|
+
* `openai-codex/<id>` model (see `@desplega/model-catalog`'s
|
|
4
|
+
* `chatgpt-subscription.ts`).
|
|
5
|
+
*
|
|
6
|
+
* The runner picks a `codex_oauth_<n>` pool slot exactly as it does for codex
|
|
7
|
+
* (`resolveCodexOAuthCredentialInfo`, same rate-limit bookkeeping) and passes
|
|
8
|
+
* it as `codexSlot`. This revalidates that slot through the locked
|
|
9
|
+
* `getValidCodexOAuth`, the sole path that ever exchanges a pool refresh
|
|
10
|
+
* token, and returns the credential with the refresh token BLANKED. The
|
|
11
|
+
* harness gets an access token it can use but never a refresh token it could
|
|
12
|
+
* rotate outside `/api/oauth/refresh-locks`: OpenAI revokes the whole token
|
|
13
|
+
* family on replay, which would kill the slot for every worker. Same contract
|
|
14
|
+
* as `credentialsToAuthJson(..., { includeRefreshToken: false })` for codex.
|
|
15
|
+
*
|
|
16
|
+
* Accepted cost: a session that outlives the access token errors instead of
|
|
17
|
+
* refreshing. `getValidCodexOAuth` hands out tokens with at least 12 h left.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { chatgptSubscriptionModelId } from "@desplega/model-catalog";
|
|
21
|
+
import { isCodexRejectedAccessError } from "../../utils/codex-auth-failure";
|
|
22
|
+
import { registerVolatileSecret } from "../../utils/secret-scrubber";
|
|
23
|
+
import type { ProviderSessionConfig } from "../types";
|
|
24
|
+
import { buildPoolRevalidationFailureReason, getValidCodexOAuth } from "./storage.js";
|
|
25
|
+
import type { CodexOAuthCredentials } from "./types.js";
|
|
26
|
+
|
|
27
|
+
export async function loadChatgptSubscriptionCredential(
|
|
28
|
+
config: Pick<ProviderSessionConfig, "apiUrl" | "apiKey" | "codexSlot" | "model">,
|
|
29
|
+
deps: { getValid?: typeof getValidCodexOAuth } = {},
|
|
30
|
+
): Promise<CodexOAuthCredentials> {
|
|
31
|
+
const slot = config.codexSlot;
|
|
32
|
+
if (slot === undefined || !config.apiUrl || !config.apiKey) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`[auth-error] Model "${config.model}" runs on the ChatGPT subscription pool, but no codex_oauth pool slot is configured. Run codex-login to add one, or pick a model with its own API key.`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
const getValid = deps.getValid ?? getValidCodexOAuth;
|
|
38
|
+
let creds: CodexOAuthCredentials | null;
|
|
39
|
+
try {
|
|
40
|
+
creds = await getValid(config.apiUrl, config.apiKey, slot);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
throw new Error(buildPoolRevalidationFailureReason(err, slot));
|
|
43
|
+
}
|
|
44
|
+
if (!creds) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`[auth-error] Codex pool slot ${slot} revalidation failed: no credentials found in config store — the slot may have just been quarantined; re-run codex-login for this slot if this persists.`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
registerVolatileSecret(creds.access, "CODEX_OAUTH_ACCESS_TOKEN");
|
|
50
|
+
return { ...creds, refresh: "" };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The `[auth-error]` failure reason for a subscription session whose access
|
|
55
|
+
* the ChatGPT backend rejected mid-run (a login revoked while its access
|
|
56
|
+
* token is unexpired), else undefined. The runner counts it toward the
|
|
57
|
+
* slot's auth-failure bench, as it does codex's turn-level 401.
|
|
58
|
+
*/
|
|
59
|
+
export function chatgptSubscriptionAuthFailure(
|
|
60
|
+
raw: string,
|
|
61
|
+
config: Pick<ProviderSessionConfig, "codexSlot" | "model">,
|
|
62
|
+
): string | undefined {
|
|
63
|
+
const slot = config.codexSlot;
|
|
64
|
+
if (slot === undefined || !chatgptSubscriptionModelId(config.model)) return undefined;
|
|
65
|
+
if (!isCodexRejectedAccessError(raw)) return undefined;
|
|
66
|
+
return `[auth-error] Codex pool slot ${slot} authentication failed — re-run agent-swarm codex-login --slot ${slot}. Original error: ${raw}`;
|
|
67
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Bun has no rename, chmod or mkdir API; file reads and writes use Bun.file/Bun.write.
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { chmod, mkdir, rename, rm } from "node:fs/promises";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { getKnownContextWindowSize } from "../utils/context-window";
|
|
6
|
+
import { FX_ROUTE_KEY_VARS, type ResolvedFxRoute, requireFxRoute } from "../utils/fx-models";
|
|
7
|
+
import { getOpenRouterBaseUrl } from "../utils/openrouter-base-url";
|
|
8
|
+
import { registerVolatileSecret } from "../utils/secret-scrubber";
|
|
9
|
+
import { ACPAdapter } from "./acp-adapter";
|
|
10
|
+
import { fxSessionExists, fxTargetProfile } from "./acp-targets";
|
|
11
|
+
import type {
|
|
12
|
+
CredStatus,
|
|
13
|
+
ProviderAdapter,
|
|
14
|
+
ProviderSession,
|
|
15
|
+
ProviderSessionConfig,
|
|
16
|
+
ProviderTraits,
|
|
17
|
+
} from "./types";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The fx release the worker image installs (`ARG FX_VERSION` in
|
|
21
|
+
* Dockerfile.worker; bump the two together). Every ACP shape this adapter
|
|
22
|
+
* relies on was measured against it.
|
|
23
|
+
*/
|
|
24
|
+
export const FX_PINNED_VERSION = "0.0.15";
|
|
25
|
+
|
|
26
|
+
/** OpenAI's Chat Completions prefix; fx appends `/chat/completions`. */
|
|
27
|
+
export const FX_OPENAI_BASE_URL = "https://api.openai.com/v1";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* fx (Vercel Labs' terminal coding agent) on the swarm's ACP client: `fx acp`
|
|
31
|
+
* is an ACP server, so spawn, swarm MCP, permissions, event translation and
|
|
32
|
+
* cost come from {@link ACPAdapter} with {@link fxTargetProfile}. This adapter
|
|
33
|
+
* resolves the model route, checks its key and seeds `~/.fx/settings.json`.
|
|
34
|
+
*
|
|
35
|
+
* Routes (`src/utils/fx-models.ts`): `openrouter/<vendor>/<id>` on
|
|
36
|
+
* OPENROUTER_API_KEY, `vercel/<vendor>/<id>` on AI_GATEWAY_API_KEY, a bare
|
|
37
|
+
* OpenAI id on OPENAI_API_KEY.
|
|
38
|
+
*/
|
|
39
|
+
export function checkFxCredentials(env: Record<string, string | undefined>): CredStatus {
|
|
40
|
+
return FX_ROUTE_KEY_VARS.some((name) => env[name]?.trim())
|
|
41
|
+
? { ready: true, missing: [], satisfiedBy: "env" }
|
|
42
|
+
: {
|
|
43
|
+
ready: false,
|
|
44
|
+
missing: [...FX_ROUTE_KEY_VARS],
|
|
45
|
+
hint: "Set OPENROUTER_API_KEY (openrouter/<vendor>/<id>), OPENAI_API_KEY (bare OpenAI ids) or AI_GATEWAY_API_KEY (vercel/<vendor>/<id>) for fx.",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type JsonObject = Record<string, unknown>;
|
|
50
|
+
|
|
51
|
+
function isJsonObject(value: unknown): value is JsonObject {
|
|
52
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** An fx custom connection: OpenAI Chat Completions with a bearer key read from `keyVar`. */
|
|
56
|
+
function connection(baseUrl: string, keyVar: string): JsonObject {
|
|
57
|
+
return {
|
|
58
|
+
protocol: "openai-chat-completions",
|
|
59
|
+
base_url: baseUrl,
|
|
60
|
+
auth: { type: "bearer", env: keyVar },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface FxSettingsInput {
|
|
65
|
+
/** The home dir whose `.fx/settings.json` fx reads (fx has no config-dir override). */
|
|
66
|
+
home: string;
|
|
67
|
+
route: ResolvedFxRoute;
|
|
68
|
+
/** The model's context window, registered so fx reports usage and compacts on time. */
|
|
69
|
+
contextWindow?: number;
|
|
70
|
+
openRouterBaseUrl: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `~/.fx/settings.json` with the swarm-owned keys merged in:
|
|
75
|
+
* - `providers.openrouter` and `providers.openai`: custom connections that
|
|
76
|
+
* name the key's env var (never its value). Fields the swarm does not own
|
|
77
|
+
* (`model_metadata`, `tool_choice_mode`, ...) are kept.
|
|
78
|
+
* - `model_metadata[<id>].context_window` for the session's model on its
|
|
79
|
+
* connection, when the catalog knows it and the file has no entry: without
|
|
80
|
+
* a window fx sends no `usage_update` and cannot time compaction.
|
|
81
|
+
* - `auto_upgrade: false` (the image pins fx) and `session_titles: false`
|
|
82
|
+
* (a title is an extra, unmetered model call).
|
|
83
|
+
* Every other key is preserved. Pure.
|
|
84
|
+
*/
|
|
85
|
+
export function mergeFxSettings(
|
|
86
|
+
current: JsonObject,
|
|
87
|
+
input: Omit<FxSettingsInput, "home">,
|
|
88
|
+
): JsonObject {
|
|
89
|
+
const next: JsonObject = structuredClone(current);
|
|
90
|
+
const providers: JsonObject = isJsonObject(next.providers) ? next.providers : {};
|
|
91
|
+
const owned: Record<string, JsonObject> = {
|
|
92
|
+
openrouter: connection(input.openRouterBaseUrl, "OPENROUTER_API_KEY"),
|
|
93
|
+
openai: connection(FX_OPENAI_BASE_URL, "OPENAI_API_KEY"),
|
|
94
|
+
};
|
|
95
|
+
for (const [name, fields] of Object.entries(owned)) {
|
|
96
|
+
const existing = isJsonObject(providers[name]) ? providers[name] : {};
|
|
97
|
+
providers[name] = { ...existing, ...fields };
|
|
98
|
+
}
|
|
99
|
+
const { fxProvider, fxModel } = input.route;
|
|
100
|
+
const target = fxProvider === "gateway" ? undefined : (providers[fxProvider] as JsonObject);
|
|
101
|
+
if (target && input.contextWindow && input.contextWindow > 0) {
|
|
102
|
+
const metadata = isJsonObject(target.model_metadata) ? target.model_metadata : {};
|
|
103
|
+
if (!isJsonObject(metadata[fxModel])) {
|
|
104
|
+
metadata[fxModel] = { context_window: input.contextWindow };
|
|
105
|
+
}
|
|
106
|
+
target.model_metadata = metadata;
|
|
107
|
+
}
|
|
108
|
+
next.providers = providers;
|
|
109
|
+
next.auto_upgrade = false;
|
|
110
|
+
next.session_titles = false;
|
|
111
|
+
return next;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Merge the swarm's keys into `<home>/.fx/settings.json` ({@link mergeFxSettings}).
|
|
116
|
+
* Idempotent: an unchanged file is not rewritten. Atomic: the new content goes
|
|
117
|
+
* to a temp file in the same dir, then replaces the old one with a rename, so
|
|
118
|
+
* a concurrent fx never reads half a file. A file that is not a JSON object is
|
|
119
|
+
* an error, not something to overwrite.
|
|
120
|
+
*/
|
|
121
|
+
export async function ensureFxSettings(input: FxSettingsInput): Promise<{
|
|
122
|
+
path: string;
|
|
123
|
+
changed: boolean;
|
|
124
|
+
}> {
|
|
125
|
+
const path = join(input.home, ".fx", "settings.json");
|
|
126
|
+
const file = Bun.file(path);
|
|
127
|
+
let current: JsonObject = {};
|
|
128
|
+
if (await file.exists()) {
|
|
129
|
+
const text = await file.text();
|
|
130
|
+
if (text.trim()) {
|
|
131
|
+
let parsed: unknown;
|
|
132
|
+
try {
|
|
133
|
+
parsed = JSON.parse(text);
|
|
134
|
+
} catch {
|
|
135
|
+
throw new Error(`${path} is not valid JSON; fix or remove it before running fx`);
|
|
136
|
+
}
|
|
137
|
+
if (!isJsonObject(parsed)) {
|
|
138
|
+
throw new Error(`${path} is not a JSON object; fix or remove it before running fx`);
|
|
139
|
+
}
|
|
140
|
+
current = parsed;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const next = mergeFxSettings(current, input);
|
|
144
|
+
if (JSON.stringify(next) === JSON.stringify(current)) return { path, changed: false };
|
|
145
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
146
|
+
const tmp = `${path}.swarm-${process.pid}-${randomUUID()}.tmp`;
|
|
147
|
+
try {
|
|
148
|
+
await Bun.write(tmp, `${JSON.stringify(next, null, 2)}\n`);
|
|
149
|
+
await chmod(tmp, 0o600);
|
|
150
|
+
await rename(tmp, path);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
await rm(tmp, { force: true });
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
return { path, changed: true };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface FxAdapterOptions {
|
|
159
|
+
/** The home dir fx runs under. Defaults to the session's `HOME`. Tests inject a temp dir. */
|
|
160
|
+
home?: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export class FxAdapter implements ProviderAdapter {
|
|
164
|
+
readonly name = "fx";
|
|
165
|
+
|
|
166
|
+
readonly traits: ProviderTraits = {
|
|
167
|
+
hasMcp: true,
|
|
168
|
+
// fx defers swarm tools behind its own `capability_search`; the prompt
|
|
169
|
+
// uses the fx-specific discovery line, not the generic tool-search one.
|
|
170
|
+
hasToolSearch: false,
|
|
171
|
+
// `~/.claude/skills` is a native fx skill root (verified: a planted skill loaded).
|
|
172
|
+
nativeSkillDiscovery: true,
|
|
173
|
+
hasLocalEnvironment: true,
|
|
174
|
+
steerModes: ["steer"],
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
private readonly acp = new ACPAdapter({ providerName: "fx", target: fxTargetProfile });
|
|
178
|
+
|
|
179
|
+
constructor(private readonly options: FxAdapterOptions = {}) {}
|
|
180
|
+
|
|
181
|
+
async createSession(sessionConfig: ProviderSessionConfig): Promise<ProviderSession> {
|
|
182
|
+
const route = requireFxRoute(sessionConfig.model);
|
|
183
|
+
const env = { ...process.env, ...sessionConfig.env };
|
|
184
|
+
const apiKey = env[route.keyVar]?.trim();
|
|
185
|
+
if (!apiKey) throw new Error(`fx requires ${route.keyVar} for ${route.model}`);
|
|
186
|
+
registerVolatileSecret(apiKey, route.keyVar);
|
|
187
|
+
const home = this.options.home ?? env.HOME;
|
|
188
|
+
if (!home) throw new Error("fx requires HOME: it keeps its settings and sessions in ~/.fx");
|
|
189
|
+
|
|
190
|
+
await ensureFxSettings({
|
|
191
|
+
home,
|
|
192
|
+
route,
|
|
193
|
+
contextWindow:
|
|
194
|
+
route.fxProvider === "gateway" ? undefined : getKnownContextWindowSize(route.model),
|
|
195
|
+
openRouterBaseUrl: getOpenRouterBaseUrl(env),
|
|
196
|
+
});
|
|
197
|
+
return this.acp.createSession({
|
|
198
|
+
...sessionConfig,
|
|
199
|
+
model: route.model,
|
|
200
|
+
env: { ...sessionConfig.env, [route.keyVar]: apiKey, HOME: home },
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async canResume(sessionId: string): Promise<boolean> {
|
|
205
|
+
return fxSessionExists(sessionId, this.options.home ?? process.env.HOME);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
formatCommand(commandName: string): string {
|
|
209
|
+
return `/${commandName}`;
|
|
210
|
+
}
|
|
211
|
+
}
|
package/src/providers/index.ts
CHANGED
|
@@ -63,13 +63,17 @@ export async function createProviderAdapter(provider: string): Promise<ProviderA
|
|
|
63
63
|
const { GrokAdapter } = await import("./grok-adapter");
|
|
64
64
|
return new GrokAdapter();
|
|
65
65
|
}
|
|
66
|
+
case "fx": {
|
|
67
|
+
const { FxAdapter } = await import("./fx-adapter");
|
|
68
|
+
return new FxAdapter();
|
|
69
|
+
}
|
|
66
70
|
case "acp": {
|
|
67
71
|
const { ACPAdapter } = await import("./acp-adapter");
|
|
68
72
|
return new ACPAdapter();
|
|
69
73
|
}
|
|
70
74
|
default:
|
|
71
75
|
throw new Error(
|
|
72
|
-
`Unknown HARNESS_PROVIDER: "${provider}". Supported: claude, pi, codex, devin, claude-managed, opencode, acp, dsh, cursor, amp, grok`,
|
|
76
|
+
`Unknown HARNESS_PROVIDER: "${provider}". Supported: claude, pi, codex, devin, claude-managed, opencode, acp, dsh, cursor, amp, grok, fx`,
|
|
73
77
|
);
|
|
74
78
|
}
|
|
75
79
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A 127.0.0.1 forwarder in front of a plain-HTTP MCP endpoint.
|
|
5
|
+
*
|
|
6
|
+
* Some ACP targets (fx) accept an MCP server URL only when it is HTTPS or an
|
|
7
|
+
* explicit loopback HTTP endpoint, and reject `http://api:3013/mcp` with
|
|
8
|
+
* `-32602`. The forwarder listens on an ephemeral loopback port and streams
|
|
9
|
+
* every request and response (SSE included) to `upstream`, so the target sees
|
|
10
|
+
* `http://127.0.0.1:<port>/…`. Worker-side HTTP only: no DB access.
|
|
11
|
+
*
|
|
12
|
+
* Header values are never logged; error text goes through `scrubSecrets`.
|
|
13
|
+
*/
|
|
14
|
+
export interface McpLoopbackProxy {
|
|
15
|
+
/** `http://127.0.0.1:<port>`, no trailing slash. Append the upstream-relative path. */
|
|
16
|
+
readonly url: string;
|
|
17
|
+
stop(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Hop-by-hop headers a proxy must not forward, plus `host` (the upstream's own). */
|
|
21
|
+
const DROPPED_REQUEST_HEADERS = [
|
|
22
|
+
"host",
|
|
23
|
+
"connection",
|
|
24
|
+
"keep-alive",
|
|
25
|
+
"proxy-connection",
|
|
26
|
+
"transfer-encoding",
|
|
27
|
+
"upgrade",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/** Hosts fx treats as explicit loopback (fx 0.0.15: `127.0.0.2` is rejected). */
|
|
31
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
32
|
+
|
|
33
|
+
/** True when `url` is plain `http://` to a host that is not an explicit loopback name. */
|
|
34
|
+
export function needsLoopbackProxy(url: string): boolean {
|
|
35
|
+
let parsed: URL;
|
|
36
|
+
try {
|
|
37
|
+
parsed = new URL(url);
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
return parsed.protocol === "http:" && !LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Start a forwarder to `upstream` (a base URL; its path is kept as a prefix).
|
|
46
|
+
* A request for `/mcp?x=1` goes to `<upstream>/mcp?x=1`.
|
|
47
|
+
*/
|
|
48
|
+
export async function startMcpLoopbackProxy(upstream: string): Promise<McpLoopbackProxy> {
|
|
49
|
+
const base = new URL(upstream);
|
|
50
|
+
const prefix = base.pathname.replace(/\/+$/, "");
|
|
51
|
+
const server = Bun.serve({
|
|
52
|
+
hostname: "127.0.0.1",
|
|
53
|
+
port: 0,
|
|
54
|
+
// SSE responses stay open with no traffic between events.
|
|
55
|
+
idleTimeout: 0,
|
|
56
|
+
async fetch(req) {
|
|
57
|
+
const incoming = new URL(req.url);
|
|
58
|
+
const target = new URL(base);
|
|
59
|
+
target.pathname = `${prefix}${incoming.pathname}`;
|
|
60
|
+
target.search = incoming.search;
|
|
61
|
+
const headers = new Headers(req.headers);
|
|
62
|
+
for (const name of DROPPED_REQUEST_HEADERS) headers.delete(name);
|
|
63
|
+
try {
|
|
64
|
+
const hasBody = req.method !== "GET" && req.method !== "HEAD";
|
|
65
|
+
return await fetch(target, {
|
|
66
|
+
method: req.method,
|
|
67
|
+
headers,
|
|
68
|
+
body: hasBody ? req.body : undefined,
|
|
69
|
+
redirect: "manual",
|
|
70
|
+
signal: req.signal,
|
|
71
|
+
// Pass bytes through untouched: decompressing here would leave a
|
|
72
|
+
// stale content-encoding header on the forwarded response.
|
|
73
|
+
decompress: false,
|
|
74
|
+
});
|
|
75
|
+
} catch (err) {
|
|
76
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
77
|
+
return new Response(
|
|
78
|
+
JSON.stringify({
|
|
79
|
+
error: scrubSecrets(`MCP loopback proxy: upstream failed: ${message}`),
|
|
80
|
+
}),
|
|
81
|
+
{ status: 502, headers: { "Content-Type": "application/json" } },
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
let stopped: Promise<void> | null = null;
|
|
87
|
+
return {
|
|
88
|
+
url: `http://127.0.0.1:${server.port}`,
|
|
89
|
+
stop() {
|
|
90
|
+
stopped ??= server.stop(true);
|
|
91
|
+
return stopped;
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -18,6 +18,8 @@ import { mkdtemp, rm } from "node:fs/promises";
|
|
|
18
18
|
import { tmpdir } from "node:os";
|
|
19
19
|
import { join } from "node:path";
|
|
20
20
|
import {
|
|
21
|
+
CHATGPT_SUBSCRIPTION_PROVIDER,
|
|
22
|
+
chatgptSubscriptionModelId,
|
|
21
23
|
ignoredBaseUrlVar,
|
|
22
24
|
type OpenAICompatibleProvider,
|
|
23
25
|
openAICompatibleKeyVars,
|
|
@@ -29,6 +31,7 @@ import {
|
|
|
29
31
|
providerKeyFromEnv,
|
|
30
32
|
providerKeyVars,
|
|
31
33
|
} from "@desplega/model-catalog";
|
|
34
|
+
import { InMemoryCredentialStore } from "@earendil-works/pi-ai";
|
|
32
35
|
import {
|
|
33
36
|
getBuiltinModel as getModel,
|
|
34
37
|
getBuiltinModels as getModels,
|
|
@@ -71,6 +74,12 @@ import { swarmRuntimeInstanceId } from "../utils/multi-runtime";
|
|
|
71
74
|
import { DEFAULT_OPENROUTER_BASE_URL, getOpenRouterBaseUrl } from "../utils/openrouter-base-url";
|
|
72
75
|
import { snapshotCatalogRows } from "../utils/runtime-model-catalog";
|
|
73
76
|
import { registerEnvSecrets, scrubObject, scrubSecrets } from "../utils/secret-scrubber";
|
|
77
|
+
import { hasCodexOAuthPoolSlots } from "./codex-oauth/env-keys.js";
|
|
78
|
+
import {
|
|
79
|
+
chatgptSubscriptionAuthFailure,
|
|
80
|
+
loadChatgptSubscriptionCredential,
|
|
81
|
+
} from "./codex-oauth/subscription.js";
|
|
82
|
+
import type { CodexOAuthCredentials } from "./codex-oauth/types.js";
|
|
74
83
|
import { readPkgVersion } from "./harness-version";
|
|
75
84
|
import { createSwarmHooksExtension } from "./pi-mono-extension";
|
|
76
85
|
import { McpHttpClient, type McpTool } from "./pi-mono-mcp-client";
|
|
@@ -328,6 +337,17 @@ export async function checkPiMonoCredentials(
|
|
|
328
337
|
}
|
|
329
338
|
}
|
|
330
339
|
|
|
340
|
+
// A ChatGPT subscription model runs on the codex_oauth pool only: the
|
|
341
|
+
// session gets a per-task in-memory credential, never auth.json or a key.
|
|
342
|
+
if (chatgptSubscriptionModelId(env.MODEL_OVERRIDE)) {
|
|
343
|
+
if (hasCodexOAuthPoolSlots(env)) return { ready: true, missing: [], satisfiedBy: "env" };
|
|
344
|
+
return {
|
|
345
|
+
ready: false,
|
|
346
|
+
missing: ["CODEX_OAUTH"],
|
|
347
|
+
hint: `MODEL_OVERRIDE=${env.MODEL_OVERRIDE} runs on the ChatGPT subscription pool; add a codex_oauth slot (codex-login) or pick a model with its own API key.`,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
331
351
|
const homeDir = opts.homeDir ?? env.HOME ?? "/root";
|
|
332
352
|
const fsProbe = opts.fs?.existsSync ?? existsSync;
|
|
333
353
|
const authFile = `${homeDir}/.pi/agent/auth.json`;
|
|
@@ -351,11 +371,13 @@ export async function checkPiMonoCredentials(
|
|
|
351
371
|
// Permissive case: any one supported key works. A registry provider key
|
|
352
372
|
// (NaN, Helmcode) only serves `<id>/*` models, but it is still a usable pi
|
|
353
373
|
// credential (the task's explicit model is checked above once it is set).
|
|
374
|
+
// The codex_oauth pool likewise serves only `openai-codex/*` models.
|
|
354
375
|
if (
|
|
355
376
|
env.ANTHROPIC_API_KEY ||
|
|
356
377
|
env.OPENROUTER_API_KEY ||
|
|
357
378
|
env.OPENAI_API_KEY ||
|
|
358
|
-
openAICompatibleKeyVars().some((k) => env[k])
|
|
379
|
+
openAICompatibleKeyVars().some((k) => env[k]) ||
|
|
380
|
+
hasCodexOAuthPoolSlots(env)
|
|
359
381
|
) {
|
|
360
382
|
return { ready: true, missing: [], satisfiedBy: "env" };
|
|
361
383
|
}
|
|
@@ -518,12 +540,16 @@ const PI_RUNTIME_API_KEYS: readonly (readonly [envKey: string, provider: string]
|
|
|
518
540
|
export async function createPiRuntimeAuth(
|
|
519
541
|
env: Record<string, string | undefined> = process.env,
|
|
520
542
|
modelsPath?: string,
|
|
543
|
+
subscription?: CodexOAuthCredentials,
|
|
521
544
|
): Promise<ModelRuntime> {
|
|
522
545
|
// A per-session models.json (`writeSessionModelsJson`) still shares the
|
|
523
546
|
// agent dir's catalog cache, which only holds built-in provider catalogs.
|
|
524
|
-
const modelRuntime = await ModelRuntime.create(
|
|
525
|
-
modelsPath
|
|
526
|
-
|
|
547
|
+
const modelRuntime = await ModelRuntime.create({
|
|
548
|
+
...(modelsPath
|
|
549
|
+
? { modelsPath, modelsStorePath: join(getAgentDir(), "models-store.json") }
|
|
550
|
+
: {}),
|
|
551
|
+
...(subscription ? { credentials: await subscriptionCredentialStore(subscription) } : {}),
|
|
552
|
+
});
|
|
527
553
|
for (const [envKey, provider] of PI_RUNTIME_API_KEYS) {
|
|
528
554
|
const apiKey = env[envKey];
|
|
529
555
|
if (apiKey) {
|
|
@@ -534,6 +560,28 @@ export async function createPiRuntimeAuth(
|
|
|
534
560
|
return modelRuntime;
|
|
535
561
|
}
|
|
536
562
|
|
|
563
|
+
/**
|
|
564
|
+
* A ChatGPT subscription session's credential store: in memory, per session,
|
|
565
|
+
* holding only the pool slot's `openai-codex` OAuth entry with the refresh
|
|
566
|
+
* token blanked (`loadChatgptSubscriptionCredential`). It replaces
|
|
567
|
+
* `~/.pi/agent/auth.json` for this session, so nothing reaches disk and
|
|
568
|
+
* concurrent sessions on different slots never share an entry. A refresh pi
|
|
569
|
+
* attempts fails on the empty token and stays in memory.
|
|
570
|
+
*/
|
|
571
|
+
async function subscriptionCredentialStore(
|
|
572
|
+
creds: CodexOAuthCredentials,
|
|
573
|
+
): Promise<InMemoryCredentialStore> {
|
|
574
|
+
const store = new InMemoryCredentialStore();
|
|
575
|
+
await store.modify(CHATGPT_SUBSCRIPTION_PROVIDER, async () => ({
|
|
576
|
+
type: "oauth",
|
|
577
|
+
access: creds.access,
|
|
578
|
+
refresh: "",
|
|
579
|
+
expires: creds.expires,
|
|
580
|
+
accountId: creds.accountId,
|
|
581
|
+
}));
|
|
582
|
+
return store;
|
|
583
|
+
}
|
|
584
|
+
|
|
537
585
|
/**
|
|
538
586
|
* Legacy sidecar marker. Earlier versions wrote the OpenRouter gateway into
|
|
539
587
|
* the shared `<agentDir>/models.json` and recorded here the `baseUrl` they
|
|
@@ -783,6 +831,9 @@ function resolveModelInner(
|
|
|
783
831
|
) {
|
|
784
832
|
if (!modelStr) return undefined;
|
|
785
833
|
|
|
834
|
+
const subscriptionId = chatgptSubscriptionModelId(modelStr);
|
|
835
|
+
if (subscriptionId) return resolveSubscriptionModel(subscriptionId, modelRuntime);
|
|
836
|
+
|
|
786
837
|
const lower = modelStr.toLowerCase();
|
|
787
838
|
const isAnthropicShortname =
|
|
788
839
|
lower === "opus" || lower === "sonnet" || lower === "haiku" || lower === "fable";
|
|
@@ -847,6 +898,24 @@ function resolveModelInner(
|
|
|
847
898
|
return undefined;
|
|
848
899
|
}
|
|
849
900
|
|
|
901
|
+
/**
|
|
902
|
+
* `openai-codex/<id>`: pi-ai's ChatGPT backend provider. Its builtin list
|
|
903
|
+
* trails the OpenAI catalog ids the picker offers (the codex harness list), so
|
|
904
|
+
* an id it does not ship reuses a shipped model's transport settings under the
|
|
905
|
+
* requested id. The ChatGPT backend rejects an id the subscription does not
|
|
906
|
+
* serve, which fails the task with the upstream error.
|
|
907
|
+
*/
|
|
908
|
+
function resolveSubscriptionModel(
|
|
909
|
+
modelId: string,
|
|
910
|
+
modelRuntime: Pick<ModelRuntime, "getModel"> | undefined,
|
|
911
|
+
) {
|
|
912
|
+
const provider = CHATGPT_SUBSCRIPTION_PROVIDER as "openai-codex";
|
|
913
|
+
const known = getModel(provider, modelId as never) ?? modelRuntime?.getModel(provider, modelId);
|
|
914
|
+
if (known) return known;
|
|
915
|
+
const template = getModels(provider)[0];
|
|
916
|
+
return template ? { ...template, id: modelId, name: modelId } : undefined;
|
|
917
|
+
}
|
|
918
|
+
|
|
850
919
|
/** Manage AGENTS.md symlink for pi-mono CLAUDE.md compatibility */
|
|
851
920
|
function createAgentsMdSymlink(cwd: string): boolean {
|
|
852
921
|
const claudeMd = join(cwd, "CLAUDE.md");
|
|
@@ -1301,6 +1370,14 @@ export class PiMonoSession implements ProviderSession {
|
|
|
1301
1370
|
}
|
|
1302
1371
|
}
|
|
1303
1372
|
|
|
1373
|
+
/** An AWS SDK failure, or a rejected ChatGPT subscription login. */
|
|
1374
|
+
private classifyFailure(raw: string): { message: string; category: string } | undefined {
|
|
1375
|
+
const aws = classifyAwsSdkError(raw);
|
|
1376
|
+
if (aws) return aws;
|
|
1377
|
+
const auth = chatgptSubscriptionAuthFailure(raw, this.config);
|
|
1378
|
+
return auth ? { message: auth, category: "authentication_failed" } : undefined;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1304
1381
|
private async runSession(): Promise<ProviderResult> {
|
|
1305
1382
|
try {
|
|
1306
1383
|
// Pi expands a leading `/skill:name` itself, with no tool call to observe.
|
|
@@ -1333,7 +1410,7 @@ export class PiMonoSession implements ProviderSession {
|
|
|
1333
1410
|
// exactly like sibling adapters. AWS errors get a categorized, actionable
|
|
1334
1411
|
// message; anything else surfaces its raw error text.
|
|
1335
1412
|
if (this.terminalError) {
|
|
1336
|
-
const classification =
|
|
1413
|
+
const classification = this.classifyFailure(this.terminalError);
|
|
1337
1414
|
const message = scrubSecrets(classification?.message ?? this.terminalError);
|
|
1338
1415
|
const category = classification?.category;
|
|
1339
1416
|
this.emit({ type: "error", message, category });
|
|
@@ -1385,13 +1462,14 @@ export class PiMonoSession implements ProviderSession {
|
|
|
1385
1462
|
// Defense-in-depth: AWS SDK failures surface as structured events (handled
|
|
1386
1463
|
// above in runSession), not thrown exceptions, so this catch is for genuine
|
|
1387
1464
|
// unexpected throws (MCP / transport / etc). Still classify in case an AWS
|
|
1388
|
-
// signature ever reaches here, so the
|
|
1389
|
-
|
|
1390
|
-
|
|
1465
|
+
// signature or a rejected subscription login ever reaches here, so the
|
|
1466
|
+
// red box fires like sibling adapters.
|
|
1467
|
+
const classifiedError = this.classifyFailure(errorMessage);
|
|
1468
|
+
if (classifiedError) {
|
|
1391
1469
|
this.emit({
|
|
1392
1470
|
type: "error",
|
|
1393
|
-
message:
|
|
1394
|
-
category:
|
|
1471
|
+
message: classifiedError.message,
|
|
1472
|
+
category: classifiedError.category,
|
|
1395
1473
|
});
|
|
1396
1474
|
}
|
|
1397
1475
|
this.emit({ type: "raw_stderr", content: `[pi-mono] Error: ${errorMessage}\n` });
|
|
@@ -1402,8 +1480,8 @@ export class PiMonoSession implements ProviderSession {
|
|
|
1402
1480
|
sessionId: this._sessionId,
|
|
1403
1481
|
cost,
|
|
1404
1482
|
isError: true,
|
|
1405
|
-
errorCategory:
|
|
1406
|
-
failureReason: scrubSecrets(
|
|
1483
|
+
errorCategory: classifiedError?.category,
|
|
1484
|
+
failureReason: scrubSecrets(classifiedError?.message ?? errorMessage),
|
|
1407
1485
|
appliedReasoningEffort: this.appliedReasoningEffort,
|
|
1408
1486
|
};
|
|
1409
1487
|
} finally {
|
|
@@ -1992,7 +2070,14 @@ export class PiMonoAdapter implements ProviderAdapter {
|
|
|
1992
2070
|
const { createdSymlink, customTools, deferTools, piMcpServers } = setup;
|
|
1993
2071
|
// The runtime is created before resolving so custom providers, which only
|
|
1994
2072
|
// exist in its composed store, can resolve.
|
|
1995
|
-
const
|
|
2073
|
+
const subscription = chatgptSubscriptionModelId(config.model)
|
|
2074
|
+
? await loadChatgptSubscriptionCredential(config)
|
|
2075
|
+
: undefined;
|
|
2076
|
+
const modelRuntime = await createPiRuntimeAuth(
|
|
2077
|
+
sessionEnv,
|
|
2078
|
+
sessionModels.modelsPath,
|
|
2079
|
+
subscription,
|
|
2080
|
+
);
|
|
1996
2081
|
const resolvedModel = resolveModel(config.model, sessionEnv, modelRuntime);
|
|
1997
2082
|
// models.json overrides (e.g. the OpenRouter gateway baseUrl) only apply
|
|
1998
2083
|
// to models resolved through the runtime's composed store; the builtin
|
|
@@ -78,6 +78,14 @@ rate by hand should also update this file.
|
|
|
78
78
|
what xAI billed (`_meta.usage.costUsdTicks`), which wins over these rows
|
|
79
79
|
as for amp. OpenRouter models also project under `provider='grok'` for
|
|
80
80
|
`openrouter/<id>` models, which report no USD.
|
|
81
|
+
- fx (`provider='fx'`) reports tokens and no USD, so every fx row is
|
|
82
|
+
priced from the table (`costSource: "pricing-table"`). The lookup strips
|
|
83
|
+
the routing prefix (`src/be/pricing-normalize.ts`): an
|
|
84
|
+
`openrouter/<vendor>/<id>` model prices at OpenRouter's `<vendor>/<id>`
|
|
85
|
+
rate, a `vercel/<vendor>/<id>` model at the AI Gateway `<vendor>/<id>`
|
|
86
|
+
rate, a bare id at OpenAI's rate. fx reports no cache split, so every
|
|
87
|
+
input token prices at the uncached rate, and its counts exclude
|
|
88
|
+
sub-agent and helper-model calls: the figure is an estimate.
|
|
81
89
|
- OpenAI, Anthropic, Google and xAI models (bare vendor ids) → rows under
|
|
82
90
|
`provider='cursor'`. Cursor bills the vendor's API rates and reports the
|
|
83
91
|
vendor's own id. The adapter subtracts cache reads and writes from SDK
|