ada-agent 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/docs/integrations.md +65 -5
- package/package.json +1 -1
- package/src/client/agent-server.ts +53 -0
- package/src/client/agent.ts +26 -11
- package/src/client/cli.ts +275 -12
- package/src/client/skill-router.ts +19 -7
- package/src/sdk/index.ts +138 -3
- package/src/selfcheck.ts +60 -1
- package/src/server/config.ts +5 -3
- package/src/server/providers/copilot-token.ts +35 -0
- package/src/server/providers/openai-compat.ts +27 -7
package/README.md
CHANGED
|
@@ -158,9 +158,10 @@ shows in the prompt line. In **ask** mode each gated tool prompts with what it w
|
|
|
158
158
|
**auto** runs tools without asking (destructive `bash` still confirms). `--yolo` starts in **auto**.
|
|
159
159
|
|
|
160
160
|
**Subcommands:** `ada mcp …` (connectors) · `ada skill add <url>` · `ada worktree add <name>` ·
|
|
161
|
-
`ada catalog [provider]` (offline model/price catalog) · `ada serve` (HTTP API
|
|
162
|
-
|
|
163
|
-
[docs/integrations.md](docs/integrations.md) for the HTTP API
|
|
161
|
+
`ada catalog [provider]` (offline model/price catalog) · `ada serve` (HTTP API — one-shot **and**
|
|
162
|
+
Cursor-style streaming sessions for building ada into your own IDE) · `ada share` (view a session) ·
|
|
163
|
+
`ada acp` (editor bridge). See [docs/integrations.md](docs/integrations.md) for the HTTP API
|
|
164
|
+
(including live SSE sessions with approval gating), the typed SDK, and ACP.
|
|
164
165
|
|
|
165
166
|
**Orchestration strategies** — the harness runs pluggable agent architectures (`--strategy <name>`
|
|
166
167
|
or `/strategy`): `react` (default loop), `single` (one shot), `plan` (plan→execute), `multi`
|
package/docs/integrations.md
CHANGED
|
@@ -9,20 +9,80 @@ build, an IdP) are described with what they'd take — they can't be "live" with
|
|
|
9
9
|
```bash
|
|
10
10
|
ada serve # → http://localhost:8788 (ADA_HTTP_PORT to change)
|
|
11
11
|
```
|
|
12
|
-
- `GET /health` → `{ ok, model }`
|
|
13
|
-
- `POST /v1/prompt` `{ "text": "...", "model"?: "..." }` → `{ text, usage }`
|
|
12
|
+
- `GET /health` → `{ ok, model, sessions }`
|
|
13
|
+
- `POST /v1/prompt` `{ "text": "...", "model"?: "..." }` → `{ text, usage }` — **one-shot**: a fresh
|
|
14
|
+
agent + fresh session per call, no memory between calls. Good for a "generate this" button, not a
|
|
15
|
+
chat panel.
|
|
16
|
+
|
|
17
|
+
### Building a Cursor-style agent panel (an IDE integration)
|
|
18
|
+
|
|
19
|
+
For a real agent panel — persistent conversation, live streamed output, visible tool calls, and
|
|
20
|
+
edits that pause for **your own** approval UI instead of auto-running — use the **interactive
|
|
21
|
+
session** endpoints instead. This is the intended integration point for a custom IDE/editor, in any
|
|
22
|
+
language, over plain HTTP + Server-Sent Events:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
GET /v1/sessions → { sessions: [{ file, title, mtime, parent? }, …] }
|
|
26
|
+
POST /v1/sessions {"resume"?: "latest"|"<file>"} → { sessionId, model, file, resumed }
|
|
27
|
+
POST /v1/sessions/:id/prompt {"text":…, "images"?: [dataURL|https…]}
|
|
28
|
+
→ SSE stream of events (see below), until "done"
|
|
29
|
+
(409 if a turn is already running on this session)
|
|
30
|
+
POST /v1/sessions/:id/approve {"id":…, "decision":"yes"|"all"|"no"}
|
|
31
|
+
POST /v1/sessions/:id/abort → cancel the running turn ("stop generating"); also
|
|
32
|
+
denies any approval it was parked on
|
|
33
|
+
POST /v1/sessions/:id/steer {"text":…} → queue a mid-turn user message (409 when idle)
|
|
34
|
+
PATCH /v1/sessions/:id {"mode":"ask"|"plan"|"auto"} → switch the permission mode live
|
|
35
|
+
DELETE /v1/sessions/:id → free the session (does not delete the transcript)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The session holds one persistent `Agent` — history, model, and skill/tool state carry across every
|
|
39
|
+
`/prompt` call. Each `/prompt` call streams one event per SSE frame (`data: {...}\n\n`):
|
|
40
|
+
|
|
41
|
+
| `type` | Fields | Meaning |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| `text` | `delta` | A chunk of the assistant's reply |
|
|
44
|
+
| `tool_call` | `name`, `detail` | A tool is about to run |
|
|
45
|
+
| `tool_result` | `name`, `output`, `isError` | It finished |
|
|
46
|
+
| `approval_request` | `id`, `name`, `summary` | **Blocks** until you POST `.../approve` with this `id` — this is where your IDE shows its own "allow this edit?" UI |
|
|
47
|
+
| `done` | `text`, `usage` | Turn complete |
|
|
48
|
+
| `error` | `message` | The turn failed (e.g. upstream unreachable) |
|
|
49
|
+
|
|
50
|
+
Sessions default to `autoApprove: false` (unlike the one-shot `/v1/prompt`, which auto-approves
|
|
51
|
+
everything) — every gated tool call (file writes, destructive shell, …) fires `approval_request` and
|
|
52
|
+
waits for your response. If no `/prompt` stream is currently open when an approval is needed, it's
|
|
53
|
+
declined (fails closed, never runs silently).
|
|
54
|
+
|
|
55
|
+
**Resuming after a restart.** Sessions live in memory, so a `sessionId` doesn't survive `ada serve`
|
|
56
|
+
restarting — but every session's conversation is also persisted to an on-disk transcript
|
|
57
|
+
(`.ada/sessions/*.jsonl`), same as the CLI's own sessions. `GET /v1/sessions` lists them (newest
|
|
58
|
+
first); pass `resume: "latest"` or a specific `file` from that list to `POST /v1/sessions` to spin up
|
|
59
|
+
a **new** in-memory session seeded with that history — the conversation picks up right where it left
|
|
60
|
+
off. Verified live: kill `ada serve` mid-conversation, restart it, resume, and the model still recalls
|
|
61
|
+
what was said before the restart.
|
|
14
62
|
|
|
15
63
|
## Typed SDK — `src/sdk`
|
|
16
64
|
|
|
17
65
|
```ts
|
|
18
66
|
import { createClient } from "ada-agent/sdk"; // in-repo: "./src/sdk/index.ts"
|
|
19
67
|
const ada = createClient("http://localhost:8788");
|
|
20
|
-
|
|
68
|
+
|
|
69
|
+
// one-shot
|
|
21
70
|
const { text } = await ada.prompt("list the files in this project");
|
|
71
|
+
|
|
72
|
+
// interactive — the IDE integration point
|
|
73
|
+
const session = await ada.session({ model: "claude-opus-4-8" });
|
|
74
|
+
await session.prompt("refactor foo.ts to use async/await", (e) => {
|
|
75
|
+
if (e.type === "text") process.stdout.write(e.delta);
|
|
76
|
+
if (e.type === "tool_call") console.log(`→ ${e.name} ${e.detail}`);
|
|
77
|
+
if (e.type === "approval_request") session.approve(e.id, myOwnConfirmUi(e) ? "yes" : "no");
|
|
78
|
+
if (e.type === "done") console.log("\n" + e.usage);
|
|
79
|
+
});
|
|
80
|
+
await session.close();
|
|
22
81
|
```
|
|
23
82
|
|
|
24
|
-
It's a
|
|
25
|
-
|
|
83
|
+
It's a `fetch`-based wrapper (manual SSE parsing, no dependency) over the HTTP API above — if you'd
|
|
84
|
+
rather not pull in the source, or your IDE isn't Node/TypeScript, talk to the same endpoints directly
|
|
85
|
+
from any HTTP client that can read a chunked response (Java, Python, Rust, a browser, …).
|
|
26
86
|
|
|
27
87
|
## ACP bridge — `ada acp`
|
|
28
88
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Pure helpers for ada's HTTP+SSE agent service (the session endpoints on `ada serve`). Kept
|
|
2
|
+
// separate from cli.ts's route wiring so the tricky bits — SSE framing, id generation, and
|
|
3
|
+
// approval correlation — are unit-testable offline, with no live model required.
|
|
4
|
+
|
|
5
|
+
import type { ApprovalDecision } from "./agent.ts";
|
|
6
|
+
|
|
7
|
+
/** Format one Server-Sent Events frame for a JSON-serializable event. */
|
|
8
|
+
export function sseFrame(event: unknown): string {
|
|
9
|
+
return `data: ${JSON.stringify(event)}\n\n`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let seq = 0;
|
|
13
|
+
/** A short, process-unique id (session id, approval-request id, …). */
|
|
14
|
+
export function newId(prefix: string): string {
|
|
15
|
+
seq++;
|
|
16
|
+
return `${prefix}_${Date.now().toString(36)}${seq.toString(36)}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Correlates a mid-turn approval request with the IDE's later response. `wait()` is called from
|
|
21
|
+
* inside the Agent's onApprove callback (which is blocked on the returned promise); `settle()` is
|
|
22
|
+
* called from the POST .../approve handler once the IDE's user has decided.
|
|
23
|
+
*/
|
|
24
|
+
export class ApprovalRegistry {
|
|
25
|
+
private pending = new Map<string, (d: ApprovalDecision) => void>();
|
|
26
|
+
|
|
27
|
+
wait(): { id: string; promise: Promise<ApprovalDecision> } {
|
|
28
|
+
const id = newId("appr");
|
|
29
|
+
const promise = new Promise<ApprovalDecision>((resolve) => this.pending.set(id, resolve));
|
|
30
|
+
return { id, promise };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Resolve a pending approval by id. False if the id is unknown (already settled, or bogus). */
|
|
34
|
+
settle(id: string, decision: ApprovalDecision): boolean {
|
|
35
|
+
const resolve = this.pending.get(id);
|
|
36
|
+
if (!resolve) return false;
|
|
37
|
+
this.pending.delete(id);
|
|
38
|
+
resolve(decision);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Deny every pending approval — an aborted turn must not stay parked on an unanswered prompt. */
|
|
43
|
+
abortAll(): number {
|
|
44
|
+
const n = this.pending.size;
|
|
45
|
+
for (const resolve of this.pending.values()) resolve("no");
|
|
46
|
+
this.pending.clear();
|
|
47
|
+
return n;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get size(): number {
|
|
51
|
+
return this.pending.size;
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/client/agent.ts
CHANGED
|
@@ -15,7 +15,14 @@ import { routeConfident, routeSkills } from "./skills.ts";
|
|
|
15
15
|
import { Session } from "./session.ts";
|
|
16
16
|
|
|
17
17
|
type Msg = OpenAI.Chat.Completions.ChatCompletionMessageParam;
|
|
18
|
-
|
|
18
|
+
/** Structured turn events — for a caller (e.g. an IDE service) that wants more than plain text.
|
|
19
|
+
* When `onEvent` is set on SendCtrl, `send()` emits these instead of writing to stdout. */
|
|
20
|
+
export type AgentEvent =
|
|
21
|
+
| { type: "text"; delta: string }
|
|
22
|
+
| { type: "tool_call"; callId: string; name: string; detail: string }
|
|
23
|
+
| { type: "tool_result"; callId: string; name: string; output: string; isError: boolean }
|
|
24
|
+
| { type: "done"; text: string; usage: string };
|
|
25
|
+
type SendCtrl = { signal?: AbortSignal; steer?: string[]; quiet?: boolean; images?: string[]; onReplyStart?: () => void; onEvent?: (e: AgentEvent) => void };
|
|
19
26
|
type ToolCall = { id: string; name: string; args: string };
|
|
20
27
|
type StepResult = { content: string; toolCalls: ToolCall[] };
|
|
21
28
|
type ToolDef = OpenAI.Chat.Completions.ChatCompletionTool;
|
|
@@ -493,6 +500,10 @@ export class Agent {
|
|
|
493
500
|
async send(input: string, ctrl?: SendCtrl): Promise<string> {
|
|
494
501
|
let replyStarted = false;
|
|
495
502
|
const say = (s: string): void => {
|
|
503
|
+
if (ctrl?.onEvent) {
|
|
504
|
+
if (s.trim()) ctrl.onEvent({ type: "text", delta: s });
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
496
507
|
if (ctrl?.quiet) return;
|
|
497
508
|
if (!replyStarted && s.trim()) {
|
|
498
509
|
replyStarted = true;
|
|
@@ -539,6 +550,7 @@ export class Agent {
|
|
|
539
550
|
|
|
540
551
|
const engine = this.makeEngine(ctrl, say, interrupted, drainSteer);
|
|
541
552
|
await (ORCHESTRATORS[this.strategy] ?? reAct).run(engine);
|
|
553
|
+
ctrl?.onEvent?.({ type: "done", text: this.lastAssistant, usage: this.usageReport() });
|
|
542
554
|
return this.lastAssistant;
|
|
543
555
|
}
|
|
544
556
|
|
|
@@ -642,6 +654,7 @@ export class Agent {
|
|
|
642
654
|
calls[tc.index] = entry;
|
|
643
655
|
}
|
|
644
656
|
if (tc.id) entry.id = tc.id;
|
|
657
|
+
else if (!entry.id) entry.id = `call_${tc.index}`; // some backends omit streamed ids — consumers key events on callId
|
|
645
658
|
if (tc.function?.name) entry.name += tc.function.name;
|
|
646
659
|
if (tc.function?.arguments) entry.args += tc.function.arguments;
|
|
647
660
|
}
|
|
@@ -687,12 +700,14 @@ export class Agent {
|
|
|
687
700
|
* append one tool message per call. */
|
|
688
701
|
private async execTools(toolCalls: ToolCall[], ctrl: SendCtrl | undefined, say: (s: string) => void): Promise<void> {
|
|
689
702
|
const signal = ctrl?.signal;
|
|
690
|
-
const printCall = (name: string, args: Record<string, unknown>): void => {
|
|
703
|
+
const printCall = (callId: string, name: string, args: Record<string, unknown>): void => {
|
|
691
704
|
const d = describeCall(name, args);
|
|
692
705
|
const detail = d.detail ? ` ${d.detail.length > 100 ? `${d.detail.slice(0, 99)}…` : d.detail}` : "";
|
|
706
|
+
ctrl?.onEvent?.({ type: "tool_call", callId, name, detail: d.detail });
|
|
693
707
|
say(`\n\x1b[2m• ${name}${detail}\x1b[0m\n`);
|
|
694
708
|
};
|
|
695
|
-
const printResult = (r: ToolResult): void => {
|
|
709
|
+
const printResult = (callId: string, name: string, r: ToolResult): void => {
|
|
710
|
+
ctrl?.onEvent?.({ type: "tool_result", callId, name, output: r.output, isError: !!r.isError });
|
|
696
711
|
if (r.display) say(`${r.display}\n`);
|
|
697
712
|
else if (r.isError) say(`\x1b[31m ${r.output.split("\n")[0]}\x1b[0m\n`);
|
|
698
713
|
};
|
|
@@ -720,15 +735,15 @@ export class Agent {
|
|
|
720
735
|
continue;
|
|
721
736
|
}
|
|
722
737
|
if (!tool) {
|
|
723
|
-
printCall(c.name, args);
|
|
738
|
+
printCall(c.id, c.name, args);
|
|
724
739
|
results[i] = { output: `Unknown tool: ${c.name}`, isError: true };
|
|
725
740
|
continue;
|
|
726
741
|
}
|
|
727
742
|
const perm = permissionFor(c.name, summarize(args)); // configured allow/ask/deny rule, if any
|
|
728
743
|
if (perm === "deny") {
|
|
729
|
-
printCall(c.name, args);
|
|
744
|
+
printCall(c.id, c.name, args);
|
|
730
745
|
results[i] = { output: "Denied by permission policy.", isError: true };
|
|
731
|
-
printResult(results[i]!);
|
|
746
|
+
printResult(c.id, c.name, results[i]!);
|
|
732
747
|
continue;
|
|
733
748
|
}
|
|
734
749
|
if (!tool.needsApproval && perm !== "ask") {
|
|
@@ -736,10 +751,10 @@ export class Agent {
|
|
|
736
751
|
continue;
|
|
737
752
|
}
|
|
738
753
|
// gated tool (or a rule forces "ask") → sequential (so prompts and same-file writes don't race)
|
|
739
|
-
printCall(c.name, args);
|
|
754
|
+
printCall(c.id, c.name, args);
|
|
740
755
|
if (this.planMode && tool.needsApproval) {
|
|
741
756
|
results[i] = { output: "Plan mode: not executing — finish the plan; the user approves with /run." };
|
|
742
|
-
printResult(results[i]!);
|
|
757
|
+
printResult(c.id, c.name, results[i]!);
|
|
743
758
|
continue;
|
|
744
759
|
}
|
|
745
760
|
const forceConfirm = c.name === "bash" && isDestructive(String(args.command ?? ""));
|
|
@@ -757,15 +772,15 @@ export class Agent {
|
|
|
757
772
|
results[i] = await runTool(tool, c.name, args);
|
|
758
773
|
}
|
|
759
774
|
}
|
|
760
|
-
printResult(results[i]!);
|
|
775
|
+
printResult(c.id, c.name, results[i]!);
|
|
761
776
|
}
|
|
762
777
|
await Promise.all(
|
|
763
778
|
parallel.map(async (i) => {
|
|
764
779
|
const c = toolCalls[i]!;
|
|
765
780
|
const args = argsOf(c.args);
|
|
766
|
-
printCall(c.name, args);
|
|
781
|
+
printCall(c.id, c.name, args);
|
|
767
782
|
results[i] = await runTool(toolByName.get(c.name)!, c.name, args);
|
|
768
|
-
printResult(results[i]!);
|
|
783
|
+
printResult(c.id, c.name, results[i]!);
|
|
769
784
|
}),
|
|
770
785
|
);
|
|
771
786
|
for (let i = 0; i < toolCalls.length; i++) {
|
package/src/client/cli.ts
CHANGED
|
@@ -7,7 +7,8 @@ import { readFileSync } from "node:fs";
|
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { stdin, stdout } from "node:process";
|
|
9
9
|
import OpenAI from "openai";
|
|
10
|
-
import { Agent, type ApprovalDecision, type OnApprove } from "./agent.ts";
|
|
10
|
+
import { Agent, type AgentEvent, type ApprovalDecision, type OnApprove } from "./agent.ts";
|
|
11
|
+
import { ApprovalRegistry, newId, sseFrame } from "./agent-server.ts";
|
|
11
12
|
import { expandPrompt, loadPrompts } from "./prompts.ts";
|
|
12
13
|
import { Session, list, type SessionMeta } from "./session.ts";
|
|
13
14
|
import { deleteCredential, getCredential, listCredentials } from "../server/credentials.ts";
|
|
@@ -501,6 +502,11 @@ const NO_BACKEND = new Set(["mcp", "skill", "worktree", "wt", "catalog", "share"
|
|
|
501
502
|
|
|
502
503
|
async function main(): Promise<void> {
|
|
503
504
|
const sub = process.argv[2];
|
|
505
|
+
if (sub === "--version" || sub === "-v" || sub === "version") {
|
|
506
|
+
// Before anything else — must not auto-start a backend just to print a version.
|
|
507
|
+
console.log(`ada ${adaVersion()}`);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
504
510
|
if (sub === "login" || sub === "logout") {
|
|
505
511
|
await authCommand(sub, process.argv[3]);
|
|
506
512
|
return;
|
|
@@ -642,9 +648,10 @@ async function main(): Promise<void> {
|
|
|
642
648
|
return;
|
|
643
649
|
}
|
|
644
650
|
if (sub === "acp") {
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
-
//
|
|
651
|
+
// Agent Client Protocol bridge over stdio (JSON-RPC 2.0, newline-delimited). Handles
|
|
652
|
+
// initialize / session/new / session/prompt, and streams session/update notifications
|
|
653
|
+
// (agent_message_chunk + tool_call/tool_call_update) while a turn runs — the shape ACP editors
|
|
654
|
+
// like Zed render live. Still experimental until exercised against a real ACP client.
|
|
648
655
|
const trusted = isTrusted(process.cwd());
|
|
649
656
|
const settings = loadSettings(trusted);
|
|
650
657
|
await loadExtensions(trusted);
|
|
@@ -661,6 +668,9 @@ async function main(): Promise<void> {
|
|
|
661
668
|
}
|
|
662
669
|
const agent = new Agent({ client, model, session: Session.create(), onApprove: async (): Promise<ApprovalDecision> => "yes", autoApprove: true, project: trusted, compactAt: settings.compactAt });
|
|
663
670
|
const send = (msg: object): void => void stdout.write(`${JSON.stringify(msg)}\n`);
|
|
671
|
+
const ACP_SESSION = newId("acp");
|
|
672
|
+
const update = (update: object): void => send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: ACP_SESSION, update } });
|
|
673
|
+
let acpCtrl: AbortController | null = null; // the in-flight prompt's abort handle (session/cancel)
|
|
664
674
|
let buf = "";
|
|
665
675
|
stdin.on("data", async (d) => {
|
|
666
676
|
buf += d.toString("utf8");
|
|
@@ -676,16 +686,29 @@ async function main(): Promise<void> {
|
|
|
676
686
|
continue;
|
|
677
687
|
}
|
|
678
688
|
if (msg.method === "initialize") send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1, agentCapabilities: { promptCapabilities: {} } } });
|
|
679
|
-
else if (msg.method === "session/new" || msg.method === "newSession") send({ jsonrpc: "2.0", id: msg.id, result: { sessionId:
|
|
680
|
-
else if (msg.method === "session/
|
|
689
|
+
else if (msg.method === "session/new" || msg.method === "newSession") send({ jsonrpc: "2.0", id: msg.id, result: { sessionId: ACP_SESSION } });
|
|
690
|
+
else if (msg.method === "session/cancel" || msg.method === "cancel") {
|
|
691
|
+
acpCtrl?.abort();
|
|
692
|
+
if (msg.id != null) send({ jsonrpc: "2.0", id: msg.id, result: {} });
|
|
693
|
+
} else if (msg.method === "session/prompt" || msg.method === "prompt") {
|
|
681
694
|
const p = msg.params ?? {};
|
|
682
695
|
const blocks = (p.prompt ?? p.text) as unknown;
|
|
683
696
|
const text = Array.isArray(blocks) ? blocks.map((b) => (b as { text?: string }).text ?? "").join("") : String(blocks ?? "");
|
|
697
|
+
acpCtrl = new AbortController();
|
|
684
698
|
try {
|
|
685
|
-
|
|
686
|
-
|
|
699
|
+
await agent.send(text, {
|
|
700
|
+
signal: acpCtrl.signal,
|
|
701
|
+
onEvent: (e: AgentEvent) => {
|
|
702
|
+
if (e.type === "text") update({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: e.delta } });
|
|
703
|
+
else if (e.type === "tool_call") update({ sessionUpdate: "tool_call", toolCallId: e.callId, title: `${e.name} ${e.detail}`.trim(), status: "in_progress" });
|
|
704
|
+
else if (e.type === "tool_result") update({ sessionUpdate: "tool_call_update", toolCallId: e.callId, status: e.isError ? "failed" : "completed" });
|
|
705
|
+
},
|
|
706
|
+
});
|
|
707
|
+
send({ jsonrpc: "2.0", id: msg.id, result: { stopReason: acpCtrl.signal.aborted ? "cancelled" : "end_turn" } });
|
|
687
708
|
} catch (e) {
|
|
688
709
|
send({ jsonrpc: "2.0", id: msg.id, error: { code: -32000, message: e instanceof Error ? e.message : String(e) } });
|
|
710
|
+
} finally {
|
|
711
|
+
acpCtrl = null;
|
|
689
712
|
}
|
|
690
713
|
} else if (msg.id != null) send({ jsonrpc: "2.0", id: msg.id, result: {} });
|
|
691
714
|
}
|
|
@@ -727,13 +750,54 @@ async function main(): Promise<void> {
|
|
|
727
750
|
}
|
|
728
751
|
}
|
|
729
752
|
const port = Number(process.env.ADA_HTTP_PORT) || 8788;
|
|
753
|
+
|
|
754
|
+
// Interactive sessions — for driving ada like an IDE agent panel (live text/tool-call events,
|
|
755
|
+
// and edits pause for YOUR approval UI instead of auto-running). See docs/integrations.md.
|
|
756
|
+
interface AgentSession {
|
|
757
|
+
agent: Agent;
|
|
758
|
+
registry: ApprovalRegistry;
|
|
759
|
+
emit: ((frame: string) => void) | null; // set only while a /prompt request's SSE stream is open
|
|
760
|
+
file: string; // the on-disk transcript — survives an `ada serve` restart; resume with it
|
|
761
|
+
ctrl: AbortController | null; // set while a turn runs — doubles as the busy flag
|
|
762
|
+
steer: string[]; // queued mid-turn user messages, drained by the agent between steps
|
|
763
|
+
mode: "ask" | "plan" | "auto";
|
|
764
|
+
}
|
|
765
|
+
const sessions = new Map<string, AgentSession>();
|
|
766
|
+
// `resumeFile` reattaches to an existing on-disk transcript (e.g. after `ada serve` restarted) —
|
|
767
|
+
// its history replays into the new in-memory Agent so the conversation picks up where it left off.
|
|
768
|
+
const makeSession = (m: string, resumeFile?: string): { id: string; rec: AgentSession } => {
|
|
769
|
+
const session = resumeFile ? Session.open(resumeFile) : Session.create();
|
|
770
|
+
const history = resumeFile ? (session.load() as unknown as Msg[]) : undefined;
|
|
771
|
+
const rec: AgentSession = { agent: undefined as unknown as Agent, registry: new ApprovalRegistry(), emit: null, file: session.file, ctrl: null, steer: [], mode: "ask" };
|
|
772
|
+
rec.agent = new Agent({
|
|
773
|
+
client,
|
|
774
|
+
model: m,
|
|
775
|
+
session,
|
|
776
|
+
history,
|
|
777
|
+
project: trusted,
|
|
778
|
+
compactAt: settings.compactAt,
|
|
779
|
+
autoApprove: false,
|
|
780
|
+
onApprove: async (toolName, summary): Promise<ApprovalDecision> => {
|
|
781
|
+
if (!rec.emit) return "no"; // no open stream to ask through — fail closed, don't silently run
|
|
782
|
+
const { id, promise } = rec.registry.wait();
|
|
783
|
+
rec.emit(sseFrame({ type: "approval_request", id, name: toolName, summary }));
|
|
784
|
+
return promise;
|
|
785
|
+
},
|
|
786
|
+
});
|
|
787
|
+
const id = newId("sess");
|
|
788
|
+
sessions.set(id, rec);
|
|
789
|
+
return { id, rec };
|
|
790
|
+
};
|
|
791
|
+
|
|
730
792
|
const { createServer } = await import("node:http");
|
|
731
793
|
createServer((req, res) => {
|
|
732
|
-
|
|
733
|
-
|
|
794
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
795
|
+
if (req.method === "GET" && (url.pathname === "/health" || url.pathname === "/")) {
|
|
796
|
+
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true, model, sessions: sessions.size }));
|
|
734
797
|
return;
|
|
735
798
|
}
|
|
736
|
-
|
|
799
|
+
// One-shot, no memory between calls — good for a "generate this" action, not a chat panel.
|
|
800
|
+
if (req.method === "POST" && url.pathname === "/v1/prompt") {
|
|
737
801
|
let body = "";
|
|
738
802
|
req.on("data", (c) => (body += c));
|
|
739
803
|
req.on("end", async () => {
|
|
@@ -748,8 +812,207 @@ async function main(): Promise<void> {
|
|
|
748
812
|
});
|
|
749
813
|
return;
|
|
750
814
|
}
|
|
815
|
+
// Interactive: persistent session, streamed events, approval round-trip.
|
|
816
|
+
// List on-disk transcripts (survive an `ada serve` restart) so an IDE can offer "resume".
|
|
817
|
+
if (req.method === "GET" && url.pathname === "/v1/sessions") {
|
|
818
|
+
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ sessions: list() }));
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
if (req.method === "POST" && url.pathname === "/v1/sessions") {
|
|
822
|
+
let body = "";
|
|
823
|
+
req.on("data", (c) => (body += c));
|
|
824
|
+
req.on("end", () => {
|
|
825
|
+
let m = model;
|
|
826
|
+
let resume: string | undefined;
|
|
827
|
+
try {
|
|
828
|
+
const j = JSON.parse(body || "{}") as { model?: string; resume?: string };
|
|
829
|
+
m = j.model || model;
|
|
830
|
+
// "latest" picks the most recently modified transcript; otherwise resume expects one of
|
|
831
|
+
// the `file` values from GET /v1/sessions (a restarted `ada serve` has no memory of which
|
|
832
|
+
// in-memory sessionIds existed before, so the IDE re-resolves by transcript file instead).
|
|
833
|
+
if (j.resume === "latest") resume = list()[0]?.file;
|
|
834
|
+
else if (j.resume && list().some((s) => s.file === j.resume)) resume = j.resume;
|
|
835
|
+
} catch {
|
|
836
|
+
/* ignore, use default model + no resume */
|
|
837
|
+
}
|
|
838
|
+
if (resume) {
|
|
839
|
+
// A live in-memory session may still be appending to that transcript (e.g. the IDE lost
|
|
840
|
+
// its SSE stream and *assumed* a restart) — two Agents on one JSONL interleave twin
|
|
841
|
+
// conversations. Point the caller at the live session instead of forking the file.
|
|
842
|
+
const live = [...sessions.entries()].find(([, r]) => r.file === resume);
|
|
843
|
+
if (live) {
|
|
844
|
+
res.writeHead(409, { "content-type": "application/json" }).end(JSON.stringify({ error: "that transcript belongs to a live session — reuse it (or DELETE it first)", sessionId: live[0], busy: !!live[1].ctrl }));
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
const { id, rec } = makeSession(m, resume);
|
|
849
|
+
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ sessionId: id, model: m, file: rec.file, resumed: !!resume }));
|
|
850
|
+
});
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
const promptMatch = req.method === "POST" && url.pathname.match(/^\/v1\/sessions\/([^/]+)\/prompt$/);
|
|
854
|
+
if (promptMatch) {
|
|
855
|
+
const rec = sessions.get(promptMatch[1]!);
|
|
856
|
+
if (!rec) {
|
|
857
|
+
res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ error: "unknown session" }));
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
if (rec.ctrl) {
|
|
861
|
+
// One turn at a time per session — two interleaved prompts would corrupt one conversation.
|
|
862
|
+
res.writeHead(409, { "content-type": "application/json" }).end(JSON.stringify({ error: "a turn is already running on this session — abort it or wait for done" }));
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
rec.ctrl = new AbortController(); // claim the session before any await, so a racing second prompt sees busy
|
|
866
|
+
// If the client dies MID-BODY (e.g. a dropped multi-MB image upload), 'end' never fires and
|
|
867
|
+
// the claim above would brick the session with a permanent 409 — release it on 'close'.
|
|
868
|
+
req.on("close", () => {
|
|
869
|
+
if (!req.complete) {
|
|
870
|
+
rec.ctrl = null;
|
|
871
|
+
rec.steer.length = 0;
|
|
872
|
+
}
|
|
873
|
+
});
|
|
874
|
+
let body = "";
|
|
875
|
+
req.on("data", (c) => (body += c));
|
|
876
|
+
req.on("end", async () => {
|
|
877
|
+
let text = "";
|
|
878
|
+
let images: string[] | undefined;
|
|
879
|
+
try {
|
|
880
|
+
const j = JSON.parse(body || "{}") as { text?: string; images?: string[] };
|
|
881
|
+
text = String(j.text ?? "");
|
|
882
|
+
if (Array.isArray(j.images) && j.images.length) images = j.images.map(String);
|
|
883
|
+
} catch {
|
|
884
|
+
/* empty prompt */
|
|
885
|
+
}
|
|
886
|
+
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
|
|
887
|
+
// If the client drops the SSE stream mid-turn (IDE reload/crash), abort the turn — else it
|
|
888
|
+
// runs headless, and in ask mode parks forever on an approval no one can see or answer.
|
|
889
|
+
res.on("close", () => {
|
|
890
|
+
if (!res.writableEnded) {
|
|
891
|
+
rec.ctrl?.abort();
|
|
892
|
+
rec.registry.abortAll();
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
rec.emit = (frame) => res.write(frame);
|
|
896
|
+
try {
|
|
897
|
+
await rec.agent.send(text, { signal: rec.ctrl!.signal, steer: rec.steer, images, onEvent: (e: AgentEvent) => res.write(sseFrame(e)) });
|
|
898
|
+
} catch (e) {
|
|
899
|
+
res.write(sseFrame({ type: "error", message: e instanceof Error ? e.message : String(e) }));
|
|
900
|
+
} finally {
|
|
901
|
+
rec.emit = null;
|
|
902
|
+
rec.ctrl = null;
|
|
903
|
+
rec.steer.length = 0;
|
|
904
|
+
res.end();
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
const abortMatch = req.method === "POST" && url.pathname.match(/^\/v1\/sessions\/([^/]+)\/abort$/);
|
|
910
|
+
if (abortMatch) {
|
|
911
|
+
const rec = sessions.get(abortMatch[1]!);
|
|
912
|
+
if (!rec) {
|
|
913
|
+
res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ error: "unknown session" }));
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
const wasRunning = !!rec.ctrl;
|
|
917
|
+
rec.ctrl?.abort();
|
|
918
|
+
rec.registry.abortAll(); // a turn parked on an unanswered approval must not stay stuck
|
|
919
|
+
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true, wasRunning }));
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
const steerMatch = req.method === "POST" && url.pathname.match(/^\/v1\/sessions\/([^/]+)\/steer$/);
|
|
923
|
+
if (steerMatch) {
|
|
924
|
+
const rec = sessions.get(steerMatch[1]!);
|
|
925
|
+
if (!rec) {
|
|
926
|
+
res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ error: "unknown session" }));
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
let body = "";
|
|
930
|
+
req.on("data", (c) => (body += c));
|
|
931
|
+
req.on("end", () => {
|
|
932
|
+
let text = "";
|
|
933
|
+
try {
|
|
934
|
+
text = String((JSON.parse(body || "{}") as { text?: string }).text ?? "");
|
|
935
|
+
} catch {
|
|
936
|
+
/* stays empty */
|
|
937
|
+
}
|
|
938
|
+
if (!text || !rec.ctrl) {
|
|
939
|
+
// steering only makes sense mid-turn; when idle, just send the next prompt instead
|
|
940
|
+
res.writeHead(409, { "content-type": "application/json" }).end(JSON.stringify({ ok: false, error: rec.ctrl ? "empty text" : "no turn running — send a prompt instead" }));
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
rec.steer.push(text);
|
|
944
|
+
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true }));
|
|
945
|
+
});
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
const modeMatch = req.method === "PATCH" && url.pathname.match(/^\/v1\/sessions\/([^/]+)$/);
|
|
949
|
+
if (modeMatch) {
|
|
950
|
+
const rec = sessions.get(modeMatch[1]!);
|
|
951
|
+
if (!rec) {
|
|
952
|
+
res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ error: "unknown session" }));
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
let body = "";
|
|
956
|
+
req.on("data", (c) => (body += c));
|
|
957
|
+
req.on("end", () => {
|
|
958
|
+
let mode: string | undefined;
|
|
959
|
+
try {
|
|
960
|
+
mode = (JSON.parse(body || "{}") as { mode?: string }).mode;
|
|
961
|
+
} catch {
|
|
962
|
+
/* stays undefined */
|
|
963
|
+
}
|
|
964
|
+
if (mode !== "ask" && mode !== "plan" && mode !== "auto") {
|
|
965
|
+
res.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: 'mode must be "ask" | "plan" | "auto"' }));
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
rec.mode = mode;
|
|
969
|
+
rec.agent.setPlanMode(mode === "plan");
|
|
970
|
+
rec.agent.setAutoApprove(mode === "auto");
|
|
971
|
+
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true, mode }));
|
|
972
|
+
});
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
const approveMatch = req.method === "POST" && url.pathname.match(/^\/v1\/sessions\/([^/]+)\/approve$/);
|
|
976
|
+
if (approveMatch) {
|
|
977
|
+
const rec = sessions.get(approveMatch[1]!);
|
|
978
|
+
if (!rec) {
|
|
979
|
+
res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ error: "unknown session" }));
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
let body = "";
|
|
983
|
+
req.on("data", (c) => (body += c));
|
|
984
|
+
req.on("end", () => {
|
|
985
|
+
let ok = false;
|
|
986
|
+
try {
|
|
987
|
+
const { id, decision } = JSON.parse(body || "{}") as { id?: string; decision?: ApprovalDecision };
|
|
988
|
+
if (id && decision) ok = rec.registry.settle(id, decision);
|
|
989
|
+
} catch {
|
|
990
|
+
/* ok stays false */
|
|
991
|
+
}
|
|
992
|
+
res.writeHead(ok ? 200 : 404, { "content-type": "application/json" }).end(JSON.stringify({ ok }));
|
|
993
|
+
});
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
const delMatch = req.method === "DELETE" && url.pathname.match(/^\/v1\/sessions\/([^/]+)$/);
|
|
997
|
+
if (delMatch) {
|
|
998
|
+
const rec = sessions.get(delMatch[1]!);
|
|
999
|
+
rec?.ctrl?.abort(); // don't orphan a running turn
|
|
1000
|
+
rec?.registry.abortAll();
|
|
1001
|
+
const existed = sessions.delete(delMatch[1]!);
|
|
1002
|
+
res.writeHead(existed ? 200 : 404, { "content-type": "application/json" }).end(JSON.stringify({ ok: existed }));
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
751
1005
|
res.writeHead(404).end();
|
|
752
|
-
}).listen(port, () =>
|
|
1006
|
+
}).listen(port, () =>
|
|
1007
|
+
console.log(
|
|
1008
|
+
`ada HTTP API on http://localhost:${port} · model ${model || "(none — set one)"}\n` +
|
|
1009
|
+
` one-shot: POST /v1/prompt {"text":"…"}\n` +
|
|
1010
|
+
` interactive: POST /v1/sessions → {sessionId} (GET lists resumable transcripts)\n` +
|
|
1011
|
+
` POST /v1/sessions/:id/prompt {"text":"…","images"?:[…]} (SSE: text/tool_call/tool_result/approval_request/done)\n` +
|
|
1012
|
+
` POST /v1/sessions/:id/approve {"id":"…","decision":"yes"|"all"|"no"}\n` +
|
|
1013
|
+
` POST /v1/sessions/:id/abort · /steer {"text":"…"} · PATCH /v1/sessions/:id {"mode":"ask"|"plan"|"auto"}`,
|
|
1014
|
+
),
|
|
1015
|
+
);
|
|
753
1016
|
await new Promise(() => {}); // keep the process alive for the server
|
|
754
1017
|
return;
|
|
755
1018
|
}
|
|
@@ -64,16 +64,28 @@ export function rankSkills(query: string, items: RankItem[], n = 5): { name: str
|
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
66
|
* The single clearly-dominant skill for a query, or null when the match is weak/ambiguous.
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
67
|
+
* Four gates, all required:
|
|
68
|
+
* 1. a score floor;
|
|
69
|
+
* 2. dominance over the runner-up;
|
|
70
|
+
* 3. an EXACT whole-token overlap with the skill NAME — the guard against prefix false positives
|
|
71
|
+
* ("make a powerpoint" prefix-matches "low-power" and even dominates, but "powerpoint" never
|
|
72
|
+
* equals the name token "power", so it's rejected);
|
|
73
|
+
* 4. query COVERAGE — strictly more than a third of the query's content tokens must EXACTLY match
|
|
74
|
+
* the skill's tokens. A conversational sentence that merely *contains* one skill-y keyword
|
|
75
|
+
* ("remember this: the secret word is X" → secret-scan, observed live) is about something else;
|
|
76
|
+
* a short task-like command ("describe the project" → project-overview) matches nearly all its
|
|
77
|
+
* tokens. Exact equality here on purpose — matches()'s 4-char prefixing is right for recall in
|
|
78
|
+
* rankSkills but inflates coverage ("remember" prefix-matches "remediate"), re-opening the leak.
|
|
71
79
|
*/
|
|
72
80
|
export function confidentSkill(query: string, items: RankItem[]): string | null {
|
|
73
81
|
const ranked = rankSkills(query, items, 2);
|
|
74
82
|
const top = ranked[0];
|
|
75
83
|
if (!top || top.score < 4) return null;
|
|
76
|
-
if (ranked[1] && top.score < ranked[1].score * 1.3) return null; // reject ties/near-ties
|
|
77
|
-
const q = new Set(tokenize(query));
|
|
78
|
-
|
|
84
|
+
if (ranked[1] && top.score < ranked[1].score * 1.3) return null; // reject ties/near-ties
|
|
85
|
+
const q = [...new Set(tokenize(query))];
|
|
86
|
+
if (!tokenize(top.name).some((t) => q.includes(t))) return null;
|
|
87
|
+
const item = items.find((it) => it.name === top.name);
|
|
88
|
+
const doc = new Set(tokenize(`${top.name} ${item?.description ?? ""} ${item?.category ?? ""}`));
|
|
89
|
+
const covered = q.filter((qt) => doc.has(qt)).length;
|
|
90
|
+
return covered / q.length > 1 / 3 ? top.name : null;
|
|
79
91
|
}
|
package/src/sdk/index.ts
CHANGED
|
@@ -1,21 +1,98 @@
|
|
|
1
|
-
// Typed client SDK for the ada HTTP API (started with `ada serve`).
|
|
1
|
+
// Typed client SDK for the ada HTTP API (started with `ada serve`). Two ways to drive ada:
|
|
2
2
|
//
|
|
3
|
-
// import { createClient } from "ada/sdk";
|
|
3
|
+
// import { createClient } from "ada-agent/sdk"; // or "./src/sdk/index.ts" in-repo
|
|
4
4
|
// const ada = createClient("http://localhost:8788");
|
|
5
|
+
//
|
|
6
|
+
// One-shot (no memory between calls — a "generate this" action, not a chat panel):
|
|
5
7
|
// const { text } = await ada.prompt("list the files in this project");
|
|
8
|
+
//
|
|
9
|
+
// Interactive (a Cursor-style agent panel — persistent session, live events, edits pause for your
|
|
10
|
+
// own approval UI instead of auto-running):
|
|
11
|
+
// const session = await ada.session();
|
|
12
|
+
// await session.prompt("refactor foo.ts", (e) => {
|
|
13
|
+
// if (e.type === "text") process.stdout.write(e.delta);
|
|
14
|
+
// if (e.type === "tool_call") console.log(`→ ${e.name} ${e.detail}`);
|
|
15
|
+
// if (e.type === "approval_request") session.approve(e.id, myOwnConfirmUi(e.name, e.summary) ? "yes" : "no");
|
|
16
|
+
// });
|
|
6
17
|
|
|
7
18
|
export interface PromptResult {
|
|
8
19
|
text: string;
|
|
9
20
|
usage?: string;
|
|
10
21
|
}
|
|
11
22
|
|
|
23
|
+
/** One event from an interactive session's prompt stream. */
|
|
24
|
+
export type SessionEvent =
|
|
25
|
+
| { type: "text"; delta: string }
|
|
26
|
+
| { type: "tool_call"; callId: string; name: string; detail: string }
|
|
27
|
+
| { type: "tool_result"; callId: string; name: string; output: string; isError: boolean }
|
|
28
|
+
| { type: "approval_request"; id: string; name: string; summary: string }
|
|
29
|
+
| { type: "done"; text: string; usage: string }
|
|
30
|
+
| { type: "error"; message: string };
|
|
31
|
+
|
|
32
|
+
export interface AdaSession {
|
|
33
|
+
readonly id: string;
|
|
34
|
+
/** The on-disk transcript backing this session — survives an `ada serve` restart. Pass this (or
|
|
35
|
+
* `"latest"`) as `resume` to a later `session()` call to reattach after one. */
|
|
36
|
+
readonly file: string;
|
|
37
|
+
/** True if this session's history was seeded from an existing transcript. */
|
|
38
|
+
readonly resumed: boolean;
|
|
39
|
+
/** Send a prompt; `onEvent` fires for every event as the turn streams. Resolves once it's done.
|
|
40
|
+
* `images` are data: or https: URLs attached to the message. 409s if a turn is already running. */
|
|
41
|
+
prompt(text: string, onEvent: (e: SessionEvent) => void, opts?: { images?: string[] }): Promise<void>;
|
|
42
|
+
/** Answer a pending `approval_request` event by its id. */
|
|
43
|
+
approve(id: string, decision: "yes" | "all" | "no"): Promise<void>;
|
|
44
|
+
/** Cancel the currently-running turn (the "stop generating" button). Safe when idle. */
|
|
45
|
+
abort(): Promise<void>;
|
|
46
|
+
/** Queue a mid-turn user message — the agent folds it in between steps (the "steer" box). */
|
|
47
|
+
steer(text: string): Promise<void>;
|
|
48
|
+
/** Switch the session's permission mode: ask (gate every edit), plan (read-only), auto (run freely). */
|
|
49
|
+
setMode(mode: "ask" | "plan" | "auto"): Promise<void>;
|
|
50
|
+
/** Free the session's resources server-side. (Does not delete the on-disk transcript.) */
|
|
51
|
+
close(): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One on-disk session transcript, as returned by `listSessions()`. */
|
|
55
|
+
export interface SessionMeta {
|
|
56
|
+
file: string;
|
|
57
|
+
mtime: number;
|
|
58
|
+
title: string;
|
|
59
|
+
parent?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
12
62
|
export interface AdaClient {
|
|
13
|
-
/**
|
|
63
|
+
/** One-shot: runs a fresh agent turn server-side (no memory between calls) and returns its final text. */
|
|
14
64
|
prompt(text: string, opts?: { model?: string }): Promise<PromptResult>;
|
|
65
|
+
/**
|
|
66
|
+
* Start a persistent, streaming session — the Cursor-style integration point for an IDE.
|
|
67
|
+
* Pass `resume: "latest"` or a `file` from `listSessions()` to reattach an existing conversation
|
|
68
|
+
* (e.g. after `ada serve` restarted and the old in-memory sessionId is gone).
|
|
69
|
+
*/
|
|
70
|
+
session(opts?: { model?: string; resume?: string }): Promise<AdaSession>;
|
|
71
|
+
/** On-disk session transcripts, newest first — for building a "resume which conversation?" picker. */
|
|
72
|
+
listSessions(): Promise<SessionMeta[]>;
|
|
15
73
|
/** Server health + the default model. */
|
|
16
74
|
health(): Promise<{ ok: boolean; model?: string }>;
|
|
17
75
|
}
|
|
18
76
|
|
|
77
|
+
async function streamSse(res: Response, onEvent: (e: SessionEvent) => void): Promise<void> {
|
|
78
|
+
if (!res.ok || !res.body) throw new Error(`ada ${res.status}: ${await res.text().catch(() => res.statusText)}`);
|
|
79
|
+
const reader = res.body.getReader();
|
|
80
|
+
const decoder = new TextDecoder();
|
|
81
|
+
let buf = "";
|
|
82
|
+
for (;;) {
|
|
83
|
+
const { done, value } = await reader.read();
|
|
84
|
+
if (done) break;
|
|
85
|
+
buf += decoder.decode(value, { stream: true });
|
|
86
|
+
let idx: number;
|
|
87
|
+
while ((idx = buf.indexOf("\n\n")) >= 0) {
|
|
88
|
+
const frame = buf.slice(0, idx);
|
|
89
|
+
buf = buf.slice(idx + 2);
|
|
90
|
+
const line = frame.split("\n").find((l) => l.startsWith("data: "));
|
|
91
|
+
if (line) onEvent(JSON.parse(line.slice(6)) as SessionEvent);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
19
96
|
export function createClient(baseUrl = "http://localhost:8788"): AdaClient {
|
|
20
97
|
const url = baseUrl.replace(/\/+$/, "");
|
|
21
98
|
return {
|
|
@@ -28,6 +105,64 @@ export function createClient(baseUrl = "http://localhost:8788"): AdaClient {
|
|
|
28
105
|
if (!res.ok) throw new Error(`ada ${res.status}: ${await res.text().catch(() => res.statusText)}`);
|
|
29
106
|
return (await res.json()) as PromptResult;
|
|
30
107
|
},
|
|
108
|
+
async session(opts) {
|
|
109
|
+
const res = await fetch(`${url}/v1/sessions`, {
|
|
110
|
+
method: "POST",
|
|
111
|
+
headers: { "content-type": "application/json" },
|
|
112
|
+
body: JSON.stringify({ model: opts?.model, resume: opts?.resume }),
|
|
113
|
+
});
|
|
114
|
+
if (!res.ok) throw new Error(`ada ${res.status}: ${await res.text().catch(() => res.statusText)}`);
|
|
115
|
+
const { sessionId, file, resumed } = (await res.json()) as { sessionId: string; file: string; resumed: boolean };
|
|
116
|
+
return {
|
|
117
|
+
id: sessionId,
|
|
118
|
+
file,
|
|
119
|
+
resumed,
|
|
120
|
+
async prompt(text, onEvent, opts) {
|
|
121
|
+
const r = await fetch(`${url}/v1/sessions/${sessionId}/prompt`, {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers: { "content-type": "application/json" },
|
|
124
|
+
body: JSON.stringify({ text, images: opts?.images }),
|
|
125
|
+
});
|
|
126
|
+
await streamSse(r, onEvent);
|
|
127
|
+
},
|
|
128
|
+
async approve(id, decision) {
|
|
129
|
+
const r = await fetch(`${url}/v1/sessions/${sessionId}/approve`, {
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers: { "content-type": "application/json" },
|
|
132
|
+
body: JSON.stringify({ id, decision }),
|
|
133
|
+
});
|
|
134
|
+
if (!r.ok) throw new Error(`ada ${r.status}: could not settle approval ${id}`);
|
|
135
|
+
},
|
|
136
|
+
async abort() {
|
|
137
|
+
const r = await fetch(`${url}/v1/sessions/${sessionId}/abort`, { method: "POST" });
|
|
138
|
+
if (!r.ok) throw new Error(`ada ${r.status}: abort failed`);
|
|
139
|
+
},
|
|
140
|
+
async steer(text) {
|
|
141
|
+
const r = await fetch(`${url}/v1/sessions/${sessionId}/steer`, {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers: { "content-type": "application/json" },
|
|
144
|
+
body: JSON.stringify({ text }),
|
|
145
|
+
});
|
|
146
|
+
if (!r.ok) throw new Error(`ada ${r.status}: steer failed (is a turn running?)`);
|
|
147
|
+
},
|
|
148
|
+
async setMode(mode) {
|
|
149
|
+
const r = await fetch(`${url}/v1/sessions/${sessionId}`, {
|
|
150
|
+
method: "PATCH",
|
|
151
|
+
headers: { "content-type": "application/json" },
|
|
152
|
+
body: JSON.stringify({ mode }),
|
|
153
|
+
});
|
|
154
|
+
if (!r.ok) throw new Error(`ada ${r.status}: could not set mode`);
|
|
155
|
+
},
|
|
156
|
+
async close() {
|
|
157
|
+
await fetch(`${url}/v1/sessions/${sessionId}`, { method: "DELETE" });
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
},
|
|
161
|
+
async listSessions() {
|
|
162
|
+
const res = await fetch(`${url}/v1/sessions`);
|
|
163
|
+
if (!res.ok) throw new Error(`ada ${res.status}: ${await res.text().catch(() => res.statusText)}`);
|
|
164
|
+
return ((await res.json()) as { sessions: SessionMeta[] }).sessions;
|
|
165
|
+
},
|
|
31
166
|
async health() {
|
|
32
167
|
const res = await fetch(`${url}/health`);
|
|
33
168
|
return (await res.json()) as { ok: boolean; model?: string };
|
package/src/selfcheck.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { expandPrompt } from "./client/prompts.ts";
|
|
|
11
11
|
import { MarkdownStreamer, highlight, renderEditDiff } from "./client/render.ts";
|
|
12
12
|
import { Session, list } from "./client/session.ts";
|
|
13
13
|
import { loadSkills, registerSkillTool, routeConfident } from "./client/skills.ts";
|
|
14
|
-
import { describeCall, parseTextToolCalls, permPhrase, readIntegrationDocs, soleIntegration, writeProjectSkills } from "./client/agent.ts";
|
|
14
|
+
import { Agent, describeCall, parseTextToolCalls, permPhrase, readIntegrationDocs, soleIntegration, writeProjectSkills } from "./client/agent.ts";
|
|
15
15
|
import { userBar } from "./client/tui.ts";
|
|
16
16
|
import { configuredServers, listConnectors, loadMcpServers } from "./client/mcp.ts";
|
|
17
17
|
import { confidentSkill, rankSkills } from "./client/skill-router.ts";
|
|
@@ -117,6 +117,18 @@ async function main(): Promise<void> {
|
|
|
117
117
|
rmSync(parent.file, { force: true });
|
|
118
118
|
rmSync(branch.file, { force: true });
|
|
119
119
|
|
|
120
|
+
// --- resume: a session's on-disk history seeds a fresh Agent's context (no live model needed) ---
|
|
121
|
+
{
|
|
122
|
+
const s = Session.create();
|
|
123
|
+
s.append({ role: "user", content: "remember: the secret word is PINEAPPLE97" });
|
|
124
|
+
s.append({ role: "assistant", content: "got it" });
|
|
125
|
+
const history = s.load() as never[];
|
|
126
|
+
const bare = new Agent({ client: {} as never, model: "x", session: Session.create(), onApprove: async () => "yes" });
|
|
127
|
+
const resumed = new Agent({ client: {} as never, model: "x", session: s, onApprove: async () => "yes", history });
|
|
128
|
+
assert.ok(resumed.contextTokens() > bare.contextTokens(), "resuming with history seeds more context than a bare session");
|
|
129
|
+
rmSync(s.file, { force: true });
|
|
130
|
+
}
|
|
131
|
+
|
|
120
132
|
// --- router prefix mapping ---
|
|
121
133
|
assert.equal(route("gpt-4o"), "openai");
|
|
122
134
|
assert.equal(route("o3-mini"), "openai");
|
|
@@ -281,6 +293,16 @@ async function main(): Promise<void> {
|
|
|
281
293
|
assert.equal(route("anything-else"), "openrouter", "unmatched → openrouter");
|
|
282
294
|
}
|
|
283
295
|
|
|
296
|
+
// --- `ada --version` prints the version and exits WITHOUT auto-starting a backend ---
|
|
297
|
+
{
|
|
298
|
+
const { spawnSync } = await import("node:child_process");
|
|
299
|
+
const { fileURLToPath } = await import("node:url");
|
|
300
|
+
const bin = fileURLToPath(new URL("../bin/ada.mjs", import.meta.url));
|
|
301
|
+
const r = spawnSync(process.execPath, [bin, "--version"], { encoding: "utf8", timeout: 30_000 });
|
|
302
|
+
assert.match(r.stdout, /^ada \d+\.\d+\.\d+/, `--version prints the version (got: ${JSON.stringify(r.stdout)} / ${JSON.stringify(r.stderr?.slice(0, 120))})`);
|
|
303
|
+
assert.ok(!/starting ada-server/.test(r.stderr ?? ""), "--version must not auto-start the backend");
|
|
304
|
+
}
|
|
305
|
+
|
|
284
306
|
// --- autostart helpers: URL classification + /health derivation ---
|
|
285
307
|
{
|
|
286
308
|
const { isLocalBackend, healthUrl } = await import("./client/autostart.ts");
|
|
@@ -299,6 +321,31 @@ async function main(): Promise<void> {
|
|
|
299
321
|
const jid = startJob("selfcheck job", async () => "job-done-ok");
|
|
300
322
|
await new Promise((r) => setTimeout(r, 30));
|
|
301
323
|
assert.ok(renderJobs().includes(jid) && /job-done-ok/.test(renderJobs()), "background job runs and reports its result");
|
|
324
|
+
|
|
325
|
+
// --- agent-server helpers: SSE framing, id uniqueness, approval correlation (no live model needed) ---
|
|
326
|
+
{
|
|
327
|
+
const { sseFrame, newId, ApprovalRegistry } = await import("./client/agent-server.ts");
|
|
328
|
+
assert.equal(sseFrame({ type: "done", text: "hi" }), 'data: {"type":"done","text":"hi"}\n\n', "sseFrame formats one data: frame");
|
|
329
|
+
const a = newId("sess");
|
|
330
|
+
const b = newId("sess");
|
|
331
|
+
assert.ok(a.startsWith("sess_") && a !== b, "newId is prefixed and unique");
|
|
332
|
+
|
|
333
|
+
const registry = new ApprovalRegistry();
|
|
334
|
+
const { id, promise } = registry.wait();
|
|
335
|
+
assert.equal(registry.size, 1, "wait() tracks one pending approval");
|
|
336
|
+
assert.ok(registry.settle(id, "yes"), "settle() resolves a known pending approval");
|
|
337
|
+
assert.equal(await promise, "yes", "the waiting promise resolves with the decision");
|
|
338
|
+
assert.equal(registry.size, 0, "settle() clears the pending entry");
|
|
339
|
+
assert.equal(registry.settle("nope", "no"), false, "settle() on an unknown id returns false");
|
|
340
|
+
|
|
341
|
+
// abortAll: an aborted turn must not stay parked on unanswered approvals
|
|
342
|
+
const a1 = registry.wait();
|
|
343
|
+
const a2 = registry.wait();
|
|
344
|
+
assert.equal(registry.abortAll(), 2, "abortAll reports how many were pending");
|
|
345
|
+
assert.equal(await a1.promise, "no", "aborted approvals resolve to 'no'");
|
|
346
|
+
assert.equal(await a2.promise, "no", "all of them");
|
|
347
|
+
assert.equal(registry.size, 0, "abortAll clears the registry");
|
|
348
|
+
}
|
|
302
349
|
assert.equal((await toolByName.get("web_fetch")!.run({ url: "http://127.0.0.1/x" })).isError, true, "web_fetch blocks loopback (SSRF guard)");
|
|
303
350
|
|
|
304
351
|
// --- destructive classifier: real dangers flagged; everyday redirects are not (2>/dev/null bug) ---
|
|
@@ -352,6 +399,18 @@ async function main(): Promise<void> {
|
|
|
352
399
|
assert.equal(confidentSkill("draw an architecture diagram of this project", allSkills), "architecture-diagram", "confident: → architecture-diagram");
|
|
353
400
|
assert.equal(confidentSkill("make a powerpoint about Q3 results", allSkills), null, "precision guard: 'powerpoint' must NOT auto-apply 'low-power'");
|
|
354
401
|
assert.equal(confidentSkill("what is 2 + 2", allSkills), null, "ambiguous query → no auto-apply");
|
|
402
|
+
// Coverage gate — a long sentence merely CONTAINING a skill-y keyword must not auto-apply
|
|
403
|
+
// (observed live: this exact prompt pulled in secret-scan and derailed a small model).
|
|
404
|
+
assert.equal(
|
|
405
|
+
confidentSkill("Remember this fact for later: the secret word is PINEAPPLE97. Just confirm you will remember it, do not do anything else.", allSkills),
|
|
406
|
+
null,
|
|
407
|
+
"coverage gate: incidental 'secret' must NOT auto-apply secret-scan",
|
|
408
|
+
);
|
|
409
|
+
assert.equal(confidentSkill("I was talking to my friend about docker yesterday and she mentioned kubernetes", allSkills), null, "coverage gate: conversational mention of docker");
|
|
410
|
+
// Short rephrasings of the same incident — prefix-matching must not inflate coverage
|
|
411
|
+
// ("remember" prefix-matches "remediate"), and 1/3 exactly must not pass the strict gate.
|
|
412
|
+
assert.equal(confidentSkill("remember this: the secret word is X", allSkills), null, "coverage gate: short secret-word phrasing");
|
|
413
|
+
assert.equal(confidentSkill("remember the secret word", allSkills), null, "coverage gate: shortest secret-word phrasing");
|
|
355
414
|
// LOADED was set by registerSkillTool(allSkills) above, so routeConfident/skillBody resolve a body.
|
|
356
415
|
const applied = routeConfident("describe the project");
|
|
357
416
|
assert.ok(applied?.name === "project-overview" && /purpose/i.test(applied.body), "routeConfident returns the skill body to inject");
|
package/src/server/config.ts
CHANGED
|
@@ -23,9 +23,9 @@ export const PROVIDERS: Record<ProviderName, ProviderDef> = {
|
|
|
23
23
|
baseURL: process.env.DASHSCOPE_BASE_URL ?? "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
|
|
24
24
|
keyEnv: "DASHSCOPE_API_KEY",
|
|
25
25
|
},
|
|
26
|
-
// GitHub Copilot — OpenAI-compatible chat endpoint. COPILOT_API_KEY
|
|
27
|
-
//
|
|
28
|
-
//
|
|
26
|
+
// GitHub Copilot — OpenAI-compatible chat endpoint. Set COPILOT_API_KEY (a Copilot bearer you
|
|
27
|
+
// already have) OR COPILOT_GITHUB_TOKEN (a GitHub token with Copilot access — the adapter runs
|
|
28
|
+
// the /copilot_internal/v2/token exchange and caches/refreshes the bearer; see copilot-token.ts).
|
|
29
29
|
copilot: { baseURL: process.env.COPILOT_BASE_URL ?? "https://api.githubcopilot.com", keyEnv: "COPILOT_API_KEY" },
|
|
30
30
|
// Cloudflare Workers AI / AI Gateway — OpenAI-compatible. Workers AI: set CLOUDFLARE_ACCOUNT_ID +
|
|
31
31
|
// CLOUDFLARE_API_TOKEN (default URL). AI Gateway: point CLOUDFLARE_BASE_URL at the gateway URL.
|
|
@@ -57,6 +57,8 @@ export function providerKey(p: ProviderName): string | undefined {
|
|
|
57
57
|
|
|
58
58
|
/** A provider is usable if it's keyless, its key env var is set, or a credential is stored. */
|
|
59
59
|
export function isConfigured(p: ProviderName): boolean {
|
|
60
|
+
// Copilot has a second way in: a GitHub token the adapter exchanges for a bearer (copilot-token.ts).
|
|
61
|
+
if (p === "copilot" && process.env.COPILOT_GITHUB_TOKEN) return true;
|
|
60
62
|
return PROVIDERS[p].keyEnv === "" || !!process.env[PROVIDERS[p].keyEnv] || !!getCredential(p);
|
|
61
63
|
}
|
|
62
64
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// GitHub Copilot bearer-token exchange. Copilot's endpoint doesn't take a GitHub token directly —
|
|
2
|
+
// you exchange one at /copilot_internal/v2/token for a short-lived bearer. Ways in, in order:
|
|
3
|
+
// COPILOT_API_KEY — you already have a bearer (pasted from another tool); used as-is.
|
|
4
|
+
// COPILOT_GITHUB_TOKEN — a GitHub OAuth token with Copilot access; exchanged + cached here,
|
|
5
|
+
// refreshed automatically before expiry.
|
|
6
|
+
// stored credential — whatever `ada login`-style credential storage holds for copilot.
|
|
7
|
+
// Untested against a live subscription (needs one) — the exchange shape matches the documented
|
|
8
|
+
// flow used by editor integrations; failures surface as a normal upstream error to the client.
|
|
9
|
+
|
|
10
|
+
import { providerKey } from "../config.ts";
|
|
11
|
+
|
|
12
|
+
let cached: { token: string; expiresAt: number } | null = null;
|
|
13
|
+
|
|
14
|
+
/** Drop the cached bearer (e.g. after an upstream 401 — revoked token or clock skew). */
|
|
15
|
+
export function invalidateCopilotBearer(): void {
|
|
16
|
+
cached = null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The bearer to send to api.githubcopilot.com, or "" if no Copilot credentials are configured. */
|
|
20
|
+
export async function copilotBearer(): Promise<string> {
|
|
21
|
+
const direct = process.env.COPILOT_API_KEY;
|
|
22
|
+
if (direct) return direct;
|
|
23
|
+
const gh = process.env.COPILOT_GITHUB_TOKEN;
|
|
24
|
+
if (!gh) return providerKey("copilot") ?? ""; // stored credential, or unconfigured
|
|
25
|
+
if (cached && Date.now() < cached.expiresAt - 60_000) return cached.token;
|
|
26
|
+
const res = await fetch("https://api.github.com/copilot_internal/v2/token", {
|
|
27
|
+
headers: { authorization: `token ${gh}`, "user-agent": "ada" },
|
|
28
|
+
signal: AbortSignal.timeout(10_000),
|
|
29
|
+
});
|
|
30
|
+
if (!res.ok) throw new Error(`Copilot token exchange failed: HTTP ${res.status} — is COPILOT_GITHUB_TOKEN a GitHub token on an account with a Copilot subscription?`);
|
|
31
|
+
const j = (await res.json()) as { token?: string; expires_at?: number };
|
|
32
|
+
if (!j.token) throw new Error("Copilot token exchange returned no token");
|
|
33
|
+
cached = { token: j.token, expiresAt: (j.expires_at ?? Math.floor(Date.now() / 1000) + 600) * 1000 };
|
|
34
|
+
return cached.token;
|
|
35
|
+
}
|
|
@@ -4,17 +4,35 @@
|
|
|
4
4
|
// that format, this adapter just swaps in the upstream base URL + key and streams the
|
|
5
5
|
// response straight back — no translation needed.
|
|
6
6
|
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
7
8
|
import type { ProviderName } from "../../shared/types.ts";
|
|
8
9
|
import { PROVIDERS, providerKey } from "../config.ts";
|
|
9
10
|
import { SSE_HEADERS } from "../sse.ts";
|
|
10
11
|
import type { Adapter, ChatRequest } from "./adapter.ts";
|
|
12
|
+
import { copilotBearer, invalidateCopilotBearer } from "./copilot-token.ts";
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
const ADA_VERSION = (() => {
|
|
15
|
+
try {
|
|
16
|
+
return (JSON.parse(readFileSync(new URL("../../../package.json", import.meta.url), "utf8")) as { version?: string }).version ?? "0.0.0";
|
|
17
|
+
} catch {
|
|
18
|
+
return "0.0.0";
|
|
19
|
+
}
|
|
20
|
+
})();
|
|
21
|
+
|
|
22
|
+
async function authHeaders(provider: ProviderName): Promise<Record<string, string>> {
|
|
23
|
+
// GitHub Copilot: bearer comes from the token exchange (or COPILOT_API_KEY), plus the
|
|
24
|
+
// editor-identification headers its endpoint requires.
|
|
25
|
+
if (provider === "copilot") {
|
|
26
|
+
const bearer = await copilotBearer();
|
|
27
|
+
return {
|
|
28
|
+
...(bearer ? { authorization: `Bearer ${bearer}` } : {}),
|
|
29
|
+
"Copilot-Integration-Id": "vscode-chat",
|
|
30
|
+
"Editor-Version": `ada/${ADA_VERSION}`,
|
|
31
|
+
"Editor-Plugin-Version": `ada/${ADA_VERSION}`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
13
34
|
const key = providerKey(provider);
|
|
14
|
-
|
|
15
|
-
// GitHub Copilot's endpoint requires these editor-identification headers.
|
|
16
|
-
if (provider === "copilot") return { ...base, "Copilot-Integration-Id": "vscode-chat", "Editor-Version": "ada/0.0.1", "Editor-Plugin-Version": "ada/0.0.1" };
|
|
17
|
-
return base;
|
|
35
|
+
return key ? { authorization: `Bearer ${key}` } : {};
|
|
18
36
|
}
|
|
19
37
|
|
|
20
38
|
export const openAICompatAdapter: Adapter = {
|
|
@@ -28,7 +46,7 @@ export const openAICompatAdapter: Adapter = {
|
|
|
28
46
|
try {
|
|
29
47
|
upstream = await fetch(`${def.baseURL}/chat/completions`, {
|
|
30
48
|
method: "POST",
|
|
31
|
-
headers: { "content-type": "application/json", ...authHeaders(provider) },
|
|
49
|
+
headers: { "content-type": "application/json", ...(await authHeaders(provider)) },
|
|
32
50
|
body: JSON.stringify(outBody),
|
|
33
51
|
});
|
|
34
52
|
} catch (e) {
|
|
@@ -42,6 +60,8 @@ export const openAICompatAdapter: Adapter = {
|
|
|
42
60
|
}
|
|
43
61
|
|
|
44
62
|
if (!upstream.ok || !upstream.body) {
|
|
63
|
+
// A dead exchanged bearer (revoked / clock skew) would otherwise be reused until local expiry.
|
|
64
|
+
if (provider === "copilot" && upstream.status === 401) invalidateCopilotBearer();
|
|
45
65
|
const text = await upstream.text().catch(() => "");
|
|
46
66
|
res.writeHead(upstream.status || 502, { "content-type": "application/json" });
|
|
47
67
|
res.end(text || JSON.stringify({ error: { message: `upstream error ${upstream.status}` } }));
|
|
@@ -67,7 +87,7 @@ export const openAICompatAdapter: Adapter = {
|
|
|
67
87
|
async listModels(provider: ProviderName): Promise<string[]> {
|
|
68
88
|
const def = PROVIDERS[provider];
|
|
69
89
|
try {
|
|
70
|
-
const r = await fetch(`${def.baseURL}/models`, { headers: authHeaders(provider) });
|
|
90
|
+
const r = await fetch(`${def.baseURL}/models`, { headers: await authHeaders(provider) });
|
|
71
91
|
if (!r.ok) return [];
|
|
72
92
|
const j = (await r.json()) as { data?: Array<{ id?: unknown }> };
|
|
73
93
|
return (j.data ?? []).map((m) => m.id).filter((x): x is string => typeof x === "string");
|