@gotcos/glasses-server 6.79.2 → 6.80.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/.env.example +9 -0
- package/CHANGELOG.md +19 -0
- package/README.md +10 -0
- package/managed-runtime-contract.json +1 -0
- package/package.json +9 -1
- package/server/index.ts +11 -0
- package/server/lib/agent-session-search.ts +8 -5
- package/server/lib/agent-session-store.ts +45 -4
- package/server/lib/archive.ts +2 -1
- package/server/lib/attached-provider-adapter.ts +11 -0
- package/server/lib/claude-bridge.ts +112 -8
- package/server/lib/claude-settings-guard.ts +94 -0
- package/server/lib/claude-steer.ts +27 -0
- package/server/lib/codex-bridge.ts +205 -32
- package/server/lib/codex-engine-sessions.ts +27 -6
- package/server/lib/codex-homes.ts +134 -0
- package/server/lib/codex-live-queue.ts +14 -0
- package/server/lib/codex-profile-home.ts +452 -0
- package/server/lib/codex-queue-control.ts +17 -1
- package/server/lib/codex-subagent-activity.ts +22 -0
- package/server/lib/conversation.ts +92 -15
- package/server/lib/direct-bridge.ts +853 -0
- package/server/lib/direct-sse.ts +262 -0
- package/server/lib/fork-thread.ts +11 -0
- package/server/lib/key-answer-fields.ts +49 -0
- package/server/lib/key-catalog.ts +301 -0
- package/server/lib/key-lanes-pricing.ts +12 -0
- package/server/lib/key-lanes-wiring.ts +346 -0
- package/server/lib/key-lanes.ts +145 -0
- package/server/lib/key-ledger.ts +123 -0
- package/server/lib/key-notices.ts +486 -0
- package/server/lib/key-profile-rejects.ts +25 -0
- package/server/lib/key-profile-routing.ts +173 -0
- package/server/lib/key-profiles.ts +182 -0
- package/server/lib/key-relay-contract.ts +262 -0
- package/server/lib/key-relay-errors.ts +211 -0
- package/server/lib/key-relay-meter.ts +303 -0
- package/server/lib/key-relay.ts +1018 -0
- package/server/lib/key-secrets-guard.ts +52 -0
- package/server/lib/key-store.ts +228 -0
- package/server/lib/keyed-sessions.ts +154 -0
- package/server/lib/lane-b-env.ts +169 -0
- package/server/lib/model-router.ts +96 -7
- package/server/lib/morning-brief-config.ts +3 -0
- package/server/lib/native-head.ts +13 -1
- package/server/lib/occupancy-probes.ts +4 -0
- package/server/lib/ollama-run-ledger.ts +96 -13
- package/server/lib/prompt-edit.ts +2 -0
- package/server/lib/query-job-coordinator.ts +2 -1
- package/server/lib/query-job-runtime.ts +67 -15
- package/server/lib/query-job-store.ts +7 -1
- package/server/lib/query-job-types.ts +15 -1
- package/server/lib/session-hook-events.ts +28 -0
- package/server/lib/session-hook-ledger.ts +10 -0
- package/server/lib/thread-occupancy.ts +22 -1
- package/server/lib/token-audit.ts +18 -4
- package/server/lib/work-handoff-requests.ts +3 -0
- package/server/routes/agent-session-bindings.ts +15 -0
- package/server/routes/agent-sessions.ts +17 -5
- package/server/routes/handoffs.ts +22 -1
- package/server/routes/health.ts +5 -0
- package/server/routes/key-lanes.ts +442 -0
- package/server/routes/openai-compat.ts +28 -3
- package/server/routes/query.ts +20 -6
- package/shared/key-preference.ts +57 -0
- package/shared/model-preference.ts +20 -2
|
@@ -0,0 +1,853 @@
|
|
|
1
|
+
// Key lanes K1 P5 (plan v4 9.1): Lane C1, direct answers on the Mac.
|
|
2
|
+
//
|
|
3
|
+
// A key profile with `engine: 'direct'` answers with one OpenAI-style chat-completions call per
|
|
4
|
+
// round to the profile's endpoint (OpenRouter, OpenAI, Anthropic's compatible endpoint, or a
|
|
5
|
+
// custom compatible URL), with the read-only COS tools when the profile may use them. No CLI, no
|
|
6
|
+
// child process.
|
|
7
|
+
//
|
|
8
|
+
// Key This module never sees one. Every request goes through the in-process DirectRelay
|
|
9
|
+
// (registered by P11 from P2's relay): it reserves against the key's daily cap,
|
|
10
|
+
// forwards with the real key, and settles what this module measured. Unregistered,
|
|
11
|
+
// every direct turn is refused before any history, ledger row or request.
|
|
12
|
+
// Tools Reused from the Ollama path (buildOllamaToolDefs, executeOllamaTool). D7: OFF for
|
|
13
|
+
// any non-loopback endpoint unless the profile opted in (`directTools`). A tool call
|
|
14
|
+
// the model makes when no tools were offered is never executed.
|
|
15
|
+
// Wire direct-sse.ts. Tool calls arrive as indexed fragments with string arguments; each
|
|
16
|
+
// result goes back as `{ role: 'tool', tool_call_id }`, never Ollama's `tool_name`.
|
|
17
|
+
// Posts At most 5 per turn (the Ollama MAX_CHAT_POSTS rule): posts 1 to 4 may run a tool
|
|
18
|
+
// batch, post 5 is the closing fetch and its calls are never run. Retries do not count.
|
|
19
|
+
// Retries Own loop (plan 4.6): 2 retries on 429, 5xx and network errors, honoring
|
|
20
|
+
// Retry-After up to 30 s; a longer Retry-After stops with the 429 line. 401, 402 and
|
|
21
|
+
// model-gone end the turn at once. The relay never retries.
|
|
22
|
+
// Copy Every key-lane line comes from P8's key-notices.ts (see loadDirectNotices).
|
|
23
|
+
//
|
|
24
|
+
// HARD RULE: nothing here runs unless a caller hands it a key profile, and the first thing it
|
|
25
|
+
// does is check keyLanesActive(). With key lanes off this module is never called and does nothing
|
|
26
|
+
// at import beyond what ollama-bridge.ts already loads.
|
|
27
|
+
|
|
28
|
+
import type { CallOptions, ModelRunMetadata, StreamCallbacks } from './claude-bridge.js'
|
|
29
|
+
import {
|
|
30
|
+
addExchange,
|
|
31
|
+
formatHistoryForPrompt,
|
|
32
|
+
getHistory,
|
|
33
|
+
getOrCreateSession,
|
|
34
|
+
getSessionRaw,
|
|
35
|
+
isNewSession,
|
|
36
|
+
markSessionNotified,
|
|
37
|
+
reconcileExchangeByJobIdentity,
|
|
38
|
+
type PromptReference,
|
|
39
|
+
} from './conversation.js'
|
|
40
|
+
import { cleanupModelImageInputs, type ModelImageInput } from './model-image-input.js'
|
|
41
|
+
import { finishDirectRun, startDirectRun } from './ollama-run-ledger.js'
|
|
42
|
+
import { notifyExchange, notifySessionStart } from './telegram-notify.js'
|
|
43
|
+
import type { ModelPreference } from '../../shared/model-preference.js'
|
|
44
|
+
import { profilePreference } from '../../shared/key-preference.js'
|
|
45
|
+
import { getCachedContextInstant } from './context-builder.js'
|
|
46
|
+
import { getOwnerName } from './profile.js'
|
|
47
|
+
import {
|
|
48
|
+
buildOllamaToolDefs,
|
|
49
|
+
executeOllamaTool,
|
|
50
|
+
OLLAMA_NO_TOOLS_SENTENCE,
|
|
51
|
+
ollamaCosPipelineConfigured,
|
|
52
|
+
parseToolArguments,
|
|
53
|
+
type OllamaToolDef,
|
|
54
|
+
} from './ollama-tools.js'
|
|
55
|
+
import { historyToOllamaMessages } from './ollama-bridge.js'
|
|
56
|
+
import { TOOL_HONESTY_CLAUSE, UNTRUSTED_CONTENT_CLAUSE } from './claude-tool-access.js'
|
|
57
|
+
import { ollamaTrailOutcome, ollamaTrailStep, type JobTrailDraft } from './job-trail.js'
|
|
58
|
+
import { keyLanesActive } from './key-lanes.js'
|
|
59
|
+
import * as keyNotices from './key-notices.js'
|
|
60
|
+
import { KEY_LANE_REFUSAL_COPY } from './key-notices.js'
|
|
61
|
+
import { classifyUpstreamStatus } from './key-relay-errors.js'
|
|
62
|
+
import { getPublicKey } from './key-store.js'
|
|
63
|
+
import { logTokenAudit } from './token-audit.js'
|
|
64
|
+
import { getKeyCatalog, keyModelPrice, keyModelSupportsTools, type KeyCatalog } from './key-catalog.js'
|
|
65
|
+
import { DirectSseParser, parseDirectJsonBody, type DirectToolCall } from './direct-sse.js'
|
|
66
|
+
import type {
|
|
67
|
+
AnswerKeyFields,
|
|
68
|
+
KeyKind,
|
|
69
|
+
KeyProfile,
|
|
70
|
+
KeyRef,
|
|
71
|
+
PublicKeyRecord,
|
|
72
|
+
RelayFailureKind,
|
|
73
|
+
} from './key-relay-contract.js'
|
|
74
|
+
|
|
75
|
+
// ── Limits ───────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
/** Requests per turn, counted as fetches like Ollama's MAX_CHAT_POSTS. Post 5 is the closing fetch. */
|
|
78
|
+
export const DIRECT_MAX_POSTS = 5
|
|
79
|
+
/** Plan 4.6: C1 retries 429, 5xx and network errors twice. */
|
|
80
|
+
export const DIRECT_MAX_RETRIES = 2
|
|
81
|
+
/** Plan 4.6: honor Retry-After up to this; a longer one stops the turn with the 429 line. */
|
|
82
|
+
export const DIRECT_RETRY_AFTER_MAX_MS = 30_000
|
|
83
|
+
/** Wait before a retry when the server sent no Retry-After. */
|
|
84
|
+
export const DIRECT_RETRY_BACKOFF_MS = [1_000, 3_000] as const
|
|
85
|
+
/** `max_tokens` on every request, so the relay's reservation is bounded (plan 4.5). */
|
|
86
|
+
export const DIRECT_MAX_OUTPUT_TOKENS = 4096
|
|
87
|
+
const INACTIVITY_MS = 60_000
|
|
88
|
+
const WALL_MAX_MS = 180_000
|
|
89
|
+
const WALL_HARD_MAX_MS = 300_000
|
|
90
|
+
const TOOL_HEARTBEAT_MS = 5_000
|
|
91
|
+
const ERROR_BODY_MAX_CHARS = 8_192
|
|
92
|
+
|
|
93
|
+
// ── Endpoints and D7 ─────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Chat-completions URL per key kind. A custom compatible key uses its own base URL. No Anthropic
|
|
97
|
+
* entry: P1's profile policy (key-profiles.ts engineModelProblem) refuses a direct profile on an
|
|
98
|
+
* Anthropic key, so an Anthropic key here answers "cannot answer direct".
|
|
99
|
+
*/
|
|
100
|
+
export const DIRECT_ENDPOINTS: Readonly<Partial<Record<KeyKind, string>>> = {
|
|
101
|
+
openrouter: 'https://openrouter.ai/api/v1/chat/completions',
|
|
102
|
+
openai: 'https://api.openai.com/v1/chat/completions',
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Where a direct turn on this key posts, or null when the key cannot answer direct. */
|
|
106
|
+
export function directEndpointFor(key: Pick<PublicKeyRecord, 'kind' | 'baseUrl'>): string | null {
|
|
107
|
+
if (key.kind === 'openai-compatible') {
|
|
108
|
+
if (!key.baseUrl) return null
|
|
109
|
+
try {
|
|
110
|
+
const u = new URL(key.baseUrl)
|
|
111
|
+
if (u.protocol !== 'https:' && u.protocol !== 'http:') return null
|
|
112
|
+
// QA N5: a key saved before the https rule still never posts in the clear to another host.
|
|
113
|
+
if (u.protocol === 'http:' && !isLoopbackEndpoint(u.toString())) return null
|
|
114
|
+
return `${key.baseUrl.replace(/\/+$/, '')}/chat/completions`
|
|
115
|
+
} catch {
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return Object.prototype.hasOwnProperty.call(DIRECT_ENDPOINTS, key.kind) ? DIRECT_ENDPOINTS[key.kind] ?? null : null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** True only for 127.0.0.0/8, ::1 and `localhost`. Anything unparseable is remote. */
|
|
123
|
+
export function isLoopbackEndpoint(url: string): boolean {
|
|
124
|
+
let host: string
|
|
125
|
+
try {
|
|
126
|
+
host = new URL(url).hostname.toLowerCase()
|
|
127
|
+
} catch {
|
|
128
|
+
return false
|
|
129
|
+
}
|
|
130
|
+
if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1)
|
|
131
|
+
if (host === 'localhost' || host === '::1') return true
|
|
132
|
+
const m = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host)
|
|
133
|
+
return !!m && m.slice(1).every(o => Number(o) <= 255)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Decision D7: COS read tools (meetings, memories) are OFF for any non-loopback endpoint unless
|
|
138
|
+
* this profile opted in. A loopback endpoint keeps meeting text on this Mac, so it gets them.
|
|
139
|
+
*/
|
|
140
|
+
export function directToolsAllowed(profile: Pick<KeyProfile, 'directTools'>, endpoint: string): boolean {
|
|
141
|
+
if (profile.directTools === true) return true
|
|
142
|
+
return isLoopbackEndpoint(endpoint)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── The relay port (in process; P2's meter and upstream client implement it) ──
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* What a direct turn is bound to. P2's KeyRelay (`issueTurn`) is the HTTP nonce relay for CLI
|
|
149
|
+
* children and its RelayEngine has no `direct`; plan 4.1 puts C1 in process ("calls the same
|
|
150
|
+
* meter and upstream client, no HTTP hop"). This is that in-process door. P11 registers an
|
|
151
|
+
* implementation backed by P2's shared meter, breakers and readKeySecretForRelay().
|
|
152
|
+
*/
|
|
153
|
+
export interface DirectTurnBinding {
|
|
154
|
+
keyRef: KeyRef
|
|
155
|
+
keyKind: KeyKind
|
|
156
|
+
profileId: string
|
|
157
|
+
turnId: string
|
|
158
|
+
sessionId: string
|
|
159
|
+
/** The profile's model; the relay forwards only this. */
|
|
160
|
+
model: string
|
|
161
|
+
/** The exact chat-completions URL this module decided D7 for. The relay posts only here. */
|
|
162
|
+
endpoint: string
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Why a direct turn or post was refused before any byte went upstream. */
|
|
166
|
+
export type DirectRefusalKind = RelayFailureKind | 'breaker_open' | 'lanes_inactive' | 'key_missing'
|
|
167
|
+
|
|
168
|
+
/** What this module measured for one post. `null` from the caller means none arrived. */
|
|
169
|
+
export interface DirectUsage {
|
|
170
|
+
input: number
|
|
171
|
+
output: number
|
|
172
|
+
/** Provider-reported cost (OpenRouter `usage.cost`); the relay prices tokens when absent. */
|
|
173
|
+
costUsd?: number
|
|
174
|
+
generationId?: string
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface DirectRelayPost {
|
|
178
|
+
/** The upstream response, untouched (status, headers, streamed body). */
|
|
179
|
+
response: Response
|
|
180
|
+
/**
|
|
181
|
+
* Call exactly once per post. `null` means no usage arrived (stream cut): the relay settles the
|
|
182
|
+
* full reservation. A non-2xx response is settled as `{ input: 0, output: 0 }` (not billed).
|
|
183
|
+
*/
|
|
184
|
+
settle(usage: DirectUsage | null): void
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface DirectRelayTurn {
|
|
188
|
+
/**
|
|
189
|
+
* Reserve against the key's daily cap, then forward `body` to the binding's endpoint with the
|
|
190
|
+
* real key. Never retries. `{ refused }` when the cap, breaker or key says no before sending.
|
|
191
|
+
*/
|
|
192
|
+
post(body: Record<string, unknown>, signal: AbortSignal): Promise<DirectRelayPost | { refused: DirectRefusalKind }>
|
|
193
|
+
/** Spent today on this key including open reservations, for the near-cap label. */
|
|
194
|
+
spentTodayUsd?(): number
|
|
195
|
+
close(): void
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface DirectRelay {
|
|
199
|
+
openTurn(binding: DirectTurnBinding): Promise<{ ok: true; turn: DirectRelayTurn } | { ok: false; refused: DirectRefusalKind }>
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
type DirectRelayResolver = () => DirectRelay | Promise<DirectRelay>
|
|
203
|
+
let relayResolver: DirectRelayResolver | null = null
|
|
204
|
+
|
|
205
|
+
/** P11 wires the real relay. Null unregisters (tests). */
|
|
206
|
+
export function registerDirectRelay(resolver: DirectRelayResolver | null): void {
|
|
207
|
+
relayResolver = resolver
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function directRelayRegistered(): boolean {
|
|
211
|
+
return relayResolver !== null
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Copy (P8 key-notices.ts) ─────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The part of P8's `server/lib/key-notices.ts` this lane uses, by its exported names and shapes.
|
|
218
|
+
* P8 is the one source of key-lane strings; the module is imported statically (P11) and these
|
|
219
|
+
* types are kept as the narrow seam tests inject through `options.notices`.
|
|
220
|
+
*/
|
|
221
|
+
export type DirectNoticeVariant = 'c1-tools-off' | 'c1-tools-on'
|
|
222
|
+
export interface DirectNoticeContext {
|
|
223
|
+
variant: DirectNoticeVariant
|
|
224
|
+
profileId: string
|
|
225
|
+
keyKind: KeyKind
|
|
226
|
+
capUsd: number
|
|
227
|
+
modelLabel: string
|
|
228
|
+
costUsd?: number
|
|
229
|
+
spentTodayUsd?: number
|
|
230
|
+
timeZone?: string
|
|
231
|
+
}
|
|
232
|
+
export type DirectNoticeSeen = Readonly<Record<string, string>>
|
|
233
|
+
export type DirectNoticeView =
|
|
234
|
+
| { form: 'first-time'; fingerprint: string; header: string; body: string[]; footer: string; label: string }
|
|
235
|
+
| { form: 'compact'; fingerprint: string; label: string }
|
|
236
|
+
export type DirectNoticeErrorId = RelayFailureKind | 'breaker_open' | 'profile_off' | 'profile_removed'
|
|
237
|
+
export interface DirectNoticeErrorParams {
|
|
238
|
+
keyKind?: KeyKind
|
|
239
|
+
capUsd?: number
|
|
240
|
+
modelLabel?: string
|
|
241
|
+
profileLabel?: string
|
|
242
|
+
now?: Date
|
|
243
|
+
timeZone?: string
|
|
244
|
+
}
|
|
245
|
+
export interface DirectNoticeCopy {
|
|
246
|
+
keyLaneNotice(ctx: DirectNoticeContext, seen: DirectNoticeSeen | undefined): DirectNoticeView
|
|
247
|
+
keyNoticeError(id: DirectNoticeErrorId, params?: DirectNoticeErrorParams): string
|
|
248
|
+
keyModelLabel(slug: string): string
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** P8's module, imported statically since the P11 merge (it is pure: no file, env, clock or socket). */
|
|
252
|
+
export async function loadDirectNotices(): Promise<DirectNoticeCopy | null> {
|
|
253
|
+
return keyNotices
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The direct-only lines, from key-notices.ts (P11 moved them there). */
|
|
257
|
+
export const DIRECT_COPY = {
|
|
258
|
+
copyUnavailable: KEY_LANE_REFUSAL_COPY.directCopyUnavailable,
|
|
259
|
+
relayUnavailable: KEY_LANE_REFUSAL_COPY.relayUnavailable,
|
|
260
|
+
wrongEngine: KEY_LANE_REFUSAL_COPY.wrongEngineDirect,
|
|
261
|
+
noEndpoint: KEY_LANE_REFUSAL_COPY.directNoEndpoint,
|
|
262
|
+
scheduled: KEY_LANE_REFUSAL_COPY.scheduled,
|
|
263
|
+
textOnly: KEY_LANE_REFUSAL_COPY.directTextOnly,
|
|
264
|
+
empty: KEY_LANE_REFUSAL_COPY.directEmpty,
|
|
265
|
+
toolCap: KEY_LANE_REFUSAL_COPY.directToolCap,
|
|
266
|
+
cancelled: KEY_LANE_REFUSAL_COPY.directCancelled,
|
|
267
|
+
} as const
|
|
268
|
+
|
|
269
|
+
// ── Status mapping (plan 4.6) ────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* P2's classifyUpstreamStatus (key-relay-errors.ts), the one model-gone pattern. Any other non-2xx
|
|
273
|
+
* it passes through (403 moderation, a plain 400) ends the turn with the upstream-error line, which
|
|
274
|
+
* never counts toward a breaker.
|
|
275
|
+
*/
|
|
276
|
+
export function classifyDirectStatus(status: number, bodyText = ''): RelayFailureKind | null {
|
|
277
|
+
if (status >= 200 && status < 300) return null
|
|
278
|
+
return classifyUpstreamStatus(status, bodyText) ?? 'upstream_error'
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const RETRYABLE: ReadonlySet<DirectRefusalKind> = new Set(['rate_limited', 'upstream_error', 'network_error'])
|
|
282
|
+
|
|
283
|
+
/** Retry-After as milliseconds (seconds or an HTTP date), or null when absent or unreadable. */
|
|
284
|
+
export function parseRetryAfterMs(value: string | null | undefined, now: () => number = Date.now): number | null {
|
|
285
|
+
if (!value || !value.trim()) return null
|
|
286
|
+
const v = value.trim()
|
|
287
|
+
if (/^\d+(\.\d+)?$/.test(v)) return Math.round(Number(v) * 1000)
|
|
288
|
+
const at = Date.parse(v)
|
|
289
|
+
return Number.isFinite(at) ? Math.max(0, at - now()) : null
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── Messages ─────────────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
export type DirectMessage =
|
|
295
|
+
| { role: 'system' | 'user'; content: string }
|
|
296
|
+
| { role: 'assistant'; content: string | null; tool_calls?: DirectToolCall[] }
|
|
297
|
+
| { role: 'tool'; tool_call_id: string; content: string }
|
|
298
|
+
|
|
299
|
+
/** One tool result, paired to its call by id (OpenAI wire). Never Ollama's `tool_name`. */
|
|
300
|
+
export function buildDirectToolMessage(call: DirectToolCall, result: string): DirectMessage {
|
|
301
|
+
return { role: 'tool', tool_call_id: call.id, content: result }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The direct system prompt. Not buildOllamaSystemPrompt: that one says "READ-ONLY Ollama path"
|
|
306
|
+
* and offers to escalate to Opus or Codex, both wrong here. Same glasses display rules.
|
|
307
|
+
*/
|
|
308
|
+
export function buildDirectSystemPrompt(input: {
|
|
309
|
+
ownerName: string
|
|
310
|
+
cachedContext: string
|
|
311
|
+
historyPrompt: string
|
|
312
|
+
toolNames: readonly string[]
|
|
313
|
+
}): string {
|
|
314
|
+
const owner = input.ownerName.trim() || 'the wearer'
|
|
315
|
+
const parts = [
|
|
316
|
+
`You are COS, ${owner}'s chief of staff, answering on smart glasses.`,
|
|
317
|
+
'Answer in plain text. No markdown, no bullet characters, no emoji.',
|
|
318
|
+
'Aim for 300 to 600 characters. Up to about 2000 when the answer genuinely needs it.',
|
|
319
|
+
]
|
|
320
|
+
if (input.cachedContext.trim()) {
|
|
321
|
+
parts.push(input.cachedContext.trim())
|
|
322
|
+
parts.push(
|
|
323
|
+
"The context block above is TODAY'S calendar and tasks. It is not the meeting archive. \"No more meetings today\" describes today's schedule only.",
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
if (input.historyPrompt.trim()) parts.push(input.historyPrompt.trim())
|
|
327
|
+
if (input.toolNames.length > 0) {
|
|
328
|
+
parts.push([
|
|
329
|
+
'This request runs on a direct, read-only path.',
|
|
330
|
+
`Available tools: ${input.toolNames.join(', ')}. Writes, Bash, MCP, web search, web fetch, and photos are not available here.`,
|
|
331
|
+
'An absent name means it is not on this path. Do not claim a connector is down.',
|
|
332
|
+
'Empty search hits plus a reason report the result of THIS call; they are not proof that no meetings or memories exist.',
|
|
333
|
+
'A read_meeting miss means not found for that path, not that the meeting does not exist.',
|
|
334
|
+
TOOL_HONESTY_CLAUSE,
|
|
335
|
+
UNTRUSTED_CONTENT_CLAUSE,
|
|
336
|
+
].join('\n'))
|
|
337
|
+
} else {
|
|
338
|
+
parts.push(OLLAMA_NO_TOOLS_SENTENCE)
|
|
339
|
+
}
|
|
340
|
+
return parts.join('\n\n')
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ── The turn ─────────────────────────────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
export interface DirectCallOptions extends CallOptions {
|
|
346
|
+
/** Tests inject; production uses the registered relay. */
|
|
347
|
+
relay?: DirectRelay
|
|
348
|
+
/** Tests inject; production loads P8's key-notices.ts. */
|
|
349
|
+
notices?: DirectNoticeCopy
|
|
350
|
+
/** Seen-state from `deviation-notices.json`; the caller persists after "got it". */
|
|
351
|
+
noticeSeen?: DirectNoticeSeen
|
|
352
|
+
/** The notice for this answer, before the first chunk (first-time form or compact label). */
|
|
353
|
+
onKeyNotice?: (view: DirectNoticeView) => void
|
|
354
|
+
catalog?: KeyCatalog
|
|
355
|
+
sleep?: (ms: number, signal: AbortSignal) => Promise<void>
|
|
356
|
+
timeZone?: string
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function defaultSleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
360
|
+
return new Promise(resolveSleep => {
|
|
361
|
+
if (signal.aborted) return resolveSleep()
|
|
362
|
+
const timer = setTimeout(resolveSleep, ms)
|
|
363
|
+
signal.addEventListener('abort', () => { clearTimeout(timer); resolveSleep() }, { once: true })
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function endpointOrigin(endpoint: string): string {
|
|
368
|
+
try { return new URL(endpoint).origin } catch { return 'invalid' }
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
type PostOutcome =
|
|
372
|
+
| { kind: 'text' }
|
|
373
|
+
| { kind: 'tools'; calls: DirectToolCall[]; text: string }
|
|
374
|
+
| { kind: 'empty' }
|
|
375
|
+
| { kind: 'failed'; failure: DirectRefusalKind | 'tool_overflow'; detail: string }
|
|
376
|
+
| { kind: 'aborted' }
|
|
377
|
+
|
|
378
|
+
export async function callDirectStreaming(
|
|
379
|
+
query: string,
|
|
380
|
+
sessionId: string | undefined,
|
|
381
|
+
callbacks: StreamCallbacks,
|
|
382
|
+
profile: KeyProfile,
|
|
383
|
+
images?: ModelImageInput[],
|
|
384
|
+
reference?: PromptReference,
|
|
385
|
+
globalMsgNum?: number,
|
|
386
|
+
options?: DirectCallOptions,
|
|
387
|
+
): Promise<string> {
|
|
388
|
+
const sid = getOrCreateSession(sessionId)
|
|
389
|
+
const imageInputs = images ?? []
|
|
390
|
+
// The predicate comes first, before any key-lane file read or import. P8's module is loaded
|
|
391
|
+
// after it only to word the refusal (it is pure: no file, env, clock or socket).
|
|
392
|
+
const active = keyLanesActive()
|
|
393
|
+
const notices = options?.notices ?? await loadDirectNotices()
|
|
394
|
+
const modelLabel = notices ? notices.keyModelLabel(profile.model) : profile.model
|
|
395
|
+
const key = active ? getPublicKey(profile.keyRef) : undefined
|
|
396
|
+
const errorParams = (): DirectNoticeErrorParams => ({
|
|
397
|
+
...(key ? { keyKind: key.kind, capUsd: key.capUsd } : {}),
|
|
398
|
+
modelLabel,
|
|
399
|
+
profileLabel: profile.label,
|
|
400
|
+
now: new Date(),
|
|
401
|
+
...(options?.timeZone ? { timeZone: options.timeZone } : {}),
|
|
402
|
+
})
|
|
403
|
+
const lensLine = (id: DirectNoticeErrorId): string => notices ? notices.keyNoticeError(id, errorParams()) : DIRECT_COPY.copyUnavailable
|
|
404
|
+
const refuse = async (line: string): Promise<string> => {
|
|
405
|
+
// QA G4: one line per refusal (the lens copy only, never a body or a key).
|
|
406
|
+
console.info(`[direct-bridge] refused profile=${profile.id}: ${line}`)
|
|
407
|
+
cleanupModelImageInputs(imageInputs)
|
|
408
|
+
await callbacks.onError(line)
|
|
409
|
+
return sid
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Every refusal runs here, before history, the run ledger or a request. The subscription is
|
|
413
|
+
// never the fallback.
|
|
414
|
+
if (!active) return refuse(lensLine('profile_off'))
|
|
415
|
+
if (profile.engine !== 'direct') return refuse(DIRECT_COPY.wrongEngine)
|
|
416
|
+
if (!profile.enabled) return refuse(lensLine('profile_off'))
|
|
417
|
+
if (options?.dispatch) return refuse(DIRECT_COPY.scheduled)
|
|
418
|
+
if (!key) return refuse(lensLine('profile_removed'))
|
|
419
|
+
if (!notices) return refuse(DIRECT_COPY.copyUnavailable)
|
|
420
|
+
if (imageInputs.length > 0 || (options?.requestAttachments?.length ?? 0) > 0) return refuse(DIRECT_COPY.textOnly)
|
|
421
|
+
const endpoint = directEndpointFor(key)
|
|
422
|
+
if (!endpoint) return refuse(DIRECT_COPY.noEndpoint)
|
|
423
|
+
let relay: DirectRelay | null = options?.relay ?? null
|
|
424
|
+
if (!relay && relayResolver) {
|
|
425
|
+
try { relay = await relayResolver() } catch { relay = null }
|
|
426
|
+
}
|
|
427
|
+
if (!relay) return refuse(DIRECT_COPY.relayUnavailable)
|
|
428
|
+
|
|
429
|
+
const turnId = options?.turnId ?? `direct-${Date.now().toString(36)}`
|
|
430
|
+
let opened: Awaited<ReturnType<DirectRelay['openTurn']>>
|
|
431
|
+
try {
|
|
432
|
+
opened = await relay.openTurn({
|
|
433
|
+
keyRef: profile.keyRef,
|
|
434
|
+
keyKind: key.kind,
|
|
435
|
+
profileId: profile.id,
|
|
436
|
+
turnId,
|
|
437
|
+
sessionId: sid,
|
|
438
|
+
model: profile.model,
|
|
439
|
+
endpoint,
|
|
440
|
+
})
|
|
441
|
+
} catch {
|
|
442
|
+
return refuse(DIRECT_COPY.relayUnavailable)
|
|
443
|
+
}
|
|
444
|
+
if (!opened.ok) return refuse(refusalLine(opened.refused, lensLine))
|
|
445
|
+
const relayTurn = opened.turn
|
|
446
|
+
|
|
447
|
+
const catalog = options?.catalog ?? getKeyCatalog()
|
|
448
|
+
catalog.refreshInBackground()
|
|
449
|
+
const catalogModels = catalog.peek()
|
|
450
|
+
const price = keyModelPrice(profile.model, catalogModels)
|
|
451
|
+
|
|
452
|
+
// D7, then the pipeline and the model. A model the catalog says cannot call tools gets none.
|
|
453
|
+
const toolsOn = directToolsAllowed(profile, endpoint)
|
|
454
|
+
&& ollamaCosPipelineConfigured()
|
|
455
|
+
&& keyModelSupportsTools(profile.model, catalogModels) !== false
|
|
456
|
+
// OpenAI's tool schema is the Ollama one (`{ type: 'function', function: {...} }`).
|
|
457
|
+
const toolDefs: OllamaToolDef[] = toolsOn ? buildOllamaToolDefs() : []
|
|
458
|
+
const toolNames = toolDefs.map(def => def.function.name)
|
|
459
|
+
|
|
460
|
+
const noticeContext = (extra: { costUsd?: number } = {}): DirectNoticeContext => ({
|
|
461
|
+
variant: toolDefs.length > 0 ? 'c1-tools-on' : 'c1-tools-off',
|
|
462
|
+
profileId: profile.id,
|
|
463
|
+
keyKind: key.kind,
|
|
464
|
+
capUsd: key.capUsd,
|
|
465
|
+
modelLabel,
|
|
466
|
+
...(relayTurn.spentTodayUsd ? { spentTodayUsd: relayTurn.spentTodayUsd() } : {}),
|
|
467
|
+
...(options?.timeZone ? { timeZone: options.timeZone } : {}),
|
|
468
|
+
...extra,
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
const history = getHistory(sid)
|
|
472
|
+
const session = getSessionRaw(sid)
|
|
473
|
+
const contextBreaks = session?.contextBreaks ?? []
|
|
474
|
+
const historyPrompt = formatHistoryForPrompt(history, contextBreaks, reference)
|
|
475
|
+
const handoffPrompt = options?.handoffContext?.promptBlock ? `\n\n${options.handoffContext.promptBlock}` : ''
|
|
476
|
+
const systemPrompt = buildDirectSystemPrompt({
|
|
477
|
+
ownerName: getOwnerName(),
|
|
478
|
+
cachedContext: getCachedContextInstant(),
|
|
479
|
+
historyPrompt: `${historyPrompt}${handoffPrompt}`,
|
|
480
|
+
toolNames,
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
// A keyed exchange is stamped with its profile (P6 persists it by syntax), never a model slot.
|
|
484
|
+
const exchangeModel = profilePreference(profile.id) as unknown as ModelPreference
|
|
485
|
+
const keyLane: AnswerKeyFields = { lane: 'direct', credential: { kind: key.kind, ref: key.ref, profileId: profile.id } }
|
|
486
|
+
const startTime = Date.now()
|
|
487
|
+
const run = startDirectRun({
|
|
488
|
+
turnId: options?.turnId,
|
|
489
|
+
clientJobId: options?.clientJobId,
|
|
490
|
+
cosSessionId: sid,
|
|
491
|
+
model: profilePreference(profile.id),
|
|
492
|
+
profile: profile.id,
|
|
493
|
+
providerModel: profile.model,
|
|
494
|
+
origin: endpointOrigin(endpoint),
|
|
495
|
+
query,
|
|
496
|
+
})
|
|
497
|
+
const startMeta: ModelRunMetadata = {
|
|
498
|
+
clientJobId: options?.clientJobId,
|
|
499
|
+
generation: options?.generation,
|
|
500
|
+
keyLane,
|
|
501
|
+
directRunId: run.runId,
|
|
502
|
+
}
|
|
503
|
+
callbacks.onStart?.(exchangeModel, sid, undefined, startMeta)
|
|
504
|
+
try { options?.onKeyNotice?.(notices.keyLaneNotice(noticeContext(), options?.noticeSeen)) } catch { /* display only */ }
|
|
505
|
+
|
|
506
|
+
const jobGeneration = options?.jobGeneration ?? options?.generation
|
|
507
|
+
const durableIdentity = options?.clientJobId && Number.isSafeInteger(jobGeneration) && jobGeneration! > 0
|
|
508
|
+
? { clientJobId: options.clientJobId, generation: jobGeneration! } : undefined
|
|
509
|
+
if (durableIdentity) {
|
|
510
|
+
reconcileExchangeByJobIdentity(sid, durableIdentity, 'user', query, globalMsgNum, undefined, undefined, exchangeModel)
|
|
511
|
+
} else {
|
|
512
|
+
addExchange(sid, 'user', query, globalMsgNum, undefined, durableIdentity, exchangeModel)
|
|
513
|
+
}
|
|
514
|
+
if (isNewSession(sid)) {
|
|
515
|
+
notifySessionStart(sid, query)
|
|
516
|
+
markSessionNotified(sid)
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const messages: DirectMessage[] = [
|
|
520
|
+
{ role: 'system', content: systemPrompt },
|
|
521
|
+
...historyToOllamaMessages(history, contextBreaks),
|
|
522
|
+
{ role: 'user', content: query },
|
|
523
|
+
]
|
|
524
|
+
|
|
525
|
+
const abort = new AbortController()
|
|
526
|
+
const onExternalAbort = () => abort.abort()
|
|
527
|
+
options?.abortSignal?.addEventListener('abort', onExternalAbort, { once: true })
|
|
528
|
+
const sleep = options?.sleep ?? defaultSleep
|
|
529
|
+
|
|
530
|
+
let inactivityTimer: ReturnType<typeof setTimeout> | undefined
|
|
531
|
+
let wallTimer: ReturnType<typeof setTimeout> | undefined
|
|
532
|
+
let toolHeartbeat: ReturnType<typeof setInterval> | undefined
|
|
533
|
+
let timedOut = false
|
|
534
|
+
const clearTimers = () => {
|
|
535
|
+
if (inactivityTimer) clearTimeout(inactivityTimer)
|
|
536
|
+
if (wallTimer) clearTimeout(wallTimer)
|
|
537
|
+
if (toolHeartbeat) { clearInterval(toolHeartbeat); toolHeartbeat = undefined }
|
|
538
|
+
}
|
|
539
|
+
const bumpInactivity = () => {
|
|
540
|
+
if (inactivityTimer) clearTimeout(inactivityTimer)
|
|
541
|
+
inactivityTimer = setTimeout(() => { timedOut = true; abort.abort() }, INACTIVITY_MS)
|
|
542
|
+
}
|
|
543
|
+
const bumpWall = () => {
|
|
544
|
+
const elapsed = Date.now() - startTime
|
|
545
|
+
const remainingToHardMax = WALL_HARD_MAX_MS - elapsed
|
|
546
|
+
if (remainingToHardMax <= 0) return
|
|
547
|
+
const next = Math.min(remainingToHardMax, Math.max(0, WALL_MAX_MS - elapsed) + 60_000)
|
|
548
|
+
if (wallTimer) clearTimeout(wallTimer)
|
|
549
|
+
wallTimer = setTimeout(() => { timedOut = true; abort.abort() }, next)
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
let fullText = ''
|
|
553
|
+
let finalized = false
|
|
554
|
+
const usageTotals = { input: 0, output: 0, usd: 0, measured: false }
|
|
555
|
+
const trail = (draft: JobTrailDraft) => {
|
|
556
|
+
if (!callbacks.onTrail) return
|
|
557
|
+
try { callbacks.onTrail(draft) } catch { /* observation only */ }
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const audit = (text: string) => {
|
|
561
|
+
logTokenAudit({
|
|
562
|
+
source: options?.lightweight ? 'g2-voice' : 'g2-query',
|
|
563
|
+
model: profile.model,
|
|
564
|
+
inputChars: messages.reduce((n, m) => n + (typeof m.content === 'string' ? m.content.length : 0), 0),
|
|
565
|
+
outputChars: text.length,
|
|
566
|
+
durationMs: Date.now() - startTime,
|
|
567
|
+
caller: 'direct_query',
|
|
568
|
+
...(options?.turnId ? { turnId: options.turnId } : {}),
|
|
569
|
+
runId: run.runId,
|
|
570
|
+
sessionId: sid,
|
|
571
|
+
...(options?.clientJobId ? { clientJobId: options.clientJobId } : {}),
|
|
572
|
+
usageKind: usageTotals.measured ? 'provider_final' : 'estimated',
|
|
573
|
+
profile: profile.id,
|
|
574
|
+
lane: 'direct',
|
|
575
|
+
credentialKind: key.kind,
|
|
576
|
+
usd: Math.round(usageTotals.usd * 1e6) / 1e6,
|
|
577
|
+
})
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const finalizeError = async (ledgerError: string, line: string) => {
|
|
581
|
+
if (finalized) return
|
|
582
|
+
finalized = true
|
|
583
|
+
clearTimers()
|
|
584
|
+
finishDirectRun(run.runId, { status: 'failed', startedAtMs: startTime, error: ledgerError })
|
|
585
|
+
audit(fullText)
|
|
586
|
+
await callbacks.onError(line)
|
|
587
|
+
}
|
|
588
|
+
const finalizeDone = async () => {
|
|
589
|
+
if (finalized) return
|
|
590
|
+
finalized = true
|
|
591
|
+
clearTimers()
|
|
592
|
+
const text = fullText.trim()
|
|
593
|
+
if (!text) {
|
|
594
|
+
finishDirectRun(run.runId, { status: 'failed', startedAtMs: startTime, error: 'direct-bridge: [empty] no answer' })
|
|
595
|
+
audit('')
|
|
596
|
+
await callbacks.onError(DIRECT_COPY.empty)
|
|
597
|
+
return
|
|
598
|
+
}
|
|
599
|
+
if (durableIdentity) {
|
|
600
|
+
reconcileExchangeByJobIdentity(sid, durableIdentity, 'assistant', text, globalMsgNum, undefined, undefined, exchangeModel)
|
|
601
|
+
} else {
|
|
602
|
+
addExchange(sid, 'assistant', text, globalMsgNum, undefined, durableIdentity, exchangeModel)
|
|
603
|
+
}
|
|
604
|
+
finishDirectRun(run.runId, { status: 'completed', startedAtMs: startTime, output: text })
|
|
605
|
+
audit(text)
|
|
606
|
+
notifyExchange(sid, query, text)
|
|
607
|
+
let keyNotice: DirectNoticeView | undefined
|
|
608
|
+
try { keyNotice = notices.keyLaneNotice(noticeContext({ costUsd: usageTotals.usd }), options?.noticeSeen) } catch { /* display only */ }
|
|
609
|
+
const doneMeta: ModelRunMetadata & { keyNotice?: DirectNoticeView } = {
|
|
610
|
+
clientJobId: options?.clientJobId,
|
|
611
|
+
generation: options?.generation,
|
|
612
|
+
keyLane,
|
|
613
|
+
directRunId: run.runId,
|
|
614
|
+
usd: usageTotals.usd,
|
|
615
|
+
...(keyNotice ? { keyNotice } : {}),
|
|
616
|
+
}
|
|
617
|
+
await callbacks.onDone(text, exchangeModel, undefined, doneMeta)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const failureLine = (failure: DirectRefusalKind | 'tool_overflow'): string =>
|
|
621
|
+
failure === 'tool_overflow' ? lensLine('upstream_error') : refusalLine(failure, lensLine)
|
|
622
|
+
|
|
623
|
+
let postCount = 0
|
|
624
|
+
let toolsRetried = false
|
|
625
|
+
let sendTools = toolDefs.length > 0
|
|
626
|
+
|
|
627
|
+
const settleOnce = (post: DirectRelayPost) => {
|
|
628
|
+
let done = false
|
|
629
|
+
return (usage: DirectUsage | null) => {
|
|
630
|
+
if (done) return
|
|
631
|
+
done = true
|
|
632
|
+
try { post.settle(usage) } catch { /* metering failure never breaks the answer */ }
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** One request, with the plan 4.6 retry loop around the status. */
|
|
637
|
+
const runPost = async (): Promise<PostOutcome> => {
|
|
638
|
+
postCount += 1
|
|
639
|
+
const body: Record<string, unknown> = {
|
|
640
|
+
model: profile.model,
|
|
641
|
+
messages,
|
|
642
|
+
stream: true,
|
|
643
|
+
stream_options: { include_usage: true },
|
|
644
|
+
max_tokens: DIRECT_MAX_OUTPUT_TOKENS,
|
|
645
|
+
}
|
|
646
|
+
if (sendTools && toolDefs.length > 0) body.tools = toolDefs
|
|
647
|
+
|
|
648
|
+
let attempt = 0
|
|
649
|
+
for (;;) {
|
|
650
|
+
if (abort.signal.aborted) return { kind: 'aborted' }
|
|
651
|
+
bumpInactivity()
|
|
652
|
+
let result: DirectRelayPost | { refused: DirectRefusalKind }
|
|
653
|
+
try {
|
|
654
|
+
result = await relayTurn.post(body, abort.signal)
|
|
655
|
+
} catch (error) {
|
|
656
|
+
if (abort.signal.aborted) return { kind: 'aborted' }
|
|
657
|
+
const detail = error instanceof Error ? error.message.slice(0, 160) : 'network error'
|
|
658
|
+
if (attempt < DIRECT_MAX_RETRIES) {
|
|
659
|
+
await sleep(DIRECT_RETRY_BACKOFF_MS[attempt] ?? 3_000, abort.signal)
|
|
660
|
+
attempt += 1
|
|
661
|
+
continue
|
|
662
|
+
}
|
|
663
|
+
return { kind: 'failed', failure: 'network_error', detail }
|
|
664
|
+
}
|
|
665
|
+
if ('refused' in result) return { kind: 'failed', failure: result.refused, detail: 'refused by relay' }
|
|
666
|
+
const settle = settleOnce(result)
|
|
667
|
+
const response = result.response
|
|
668
|
+
if (!response.ok) {
|
|
669
|
+
const detail = (await response.text().catch(() => '')).slice(0, ERROR_BODY_MAX_CHARS)
|
|
670
|
+
settle({ input: 0, output: 0 })
|
|
671
|
+
// A server that rejects the tools key gets ONE retry without it, first post only, and the
|
|
672
|
+
// retry does not take a post slot (the Ollama rule).
|
|
673
|
+
if (response.status === 400 && body.tools && !toolsRetried && postCount === 1 && /tool/i.test(detail)) {
|
|
674
|
+
toolsRetried = true
|
|
675
|
+
sendTools = false
|
|
676
|
+
delete body.tools
|
|
677
|
+
continue
|
|
678
|
+
}
|
|
679
|
+
const failure = classifyDirectStatus(response.status, detail) ?? 'upstream_error'
|
|
680
|
+
if (RETRYABLE.has(failure) && attempt < DIRECT_MAX_RETRIES) {
|
|
681
|
+
const retryAfter = parseRetryAfterMs(response.headers.get('retry-after'))
|
|
682
|
+
if (retryAfter !== null && retryAfter > DIRECT_RETRY_AFTER_MAX_MS) {
|
|
683
|
+
return { kind: 'failed', failure, detail: `HTTP ${response.status} retry-after ${retryAfter}ms` }
|
|
684
|
+
}
|
|
685
|
+
await sleep(retryAfter ?? DIRECT_RETRY_BACKOFF_MS[attempt] ?? 3_000, abort.signal)
|
|
686
|
+
attempt += 1
|
|
687
|
+
continue
|
|
688
|
+
}
|
|
689
|
+
return { kind: 'failed', failure, detail: `HTTP ${response.status} ${detail.replace(/\s+/g, ' ').slice(0, 160)}` }
|
|
690
|
+
}
|
|
691
|
+
return readStream(response, settle)
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const readStream = async (response: Response, settle: (u: DirectUsage | null) => void): Promise<PostOutcome> => {
|
|
696
|
+
const idPrefix = `call_${run.runId}_${postCount}`
|
|
697
|
+
const roundStart = fullText.length
|
|
698
|
+
const onText = (text: string) => {
|
|
699
|
+
fullText += text
|
|
700
|
+
callbacks.onChunk(text)
|
|
701
|
+
}
|
|
702
|
+
let parser: DirectSseParser
|
|
703
|
+
let streamError: string | null = null
|
|
704
|
+
try {
|
|
705
|
+
const contentType = response.headers.get('content-type') ?? ''
|
|
706
|
+
if (/application\/json/i.test(contentType) && !/event-stream/i.test(contentType)) {
|
|
707
|
+
const parsed = parseDirectJsonBody(await response.text(), idPrefix)
|
|
708
|
+
parser = parsed.parser
|
|
709
|
+
for (const ev of parsed.events) {
|
|
710
|
+
if (ev.type === 'text') onText(ev.text)
|
|
711
|
+
else if (ev.type === 'error') streamError = ev.message
|
|
712
|
+
}
|
|
713
|
+
} else {
|
|
714
|
+
if (!response.body) {
|
|
715
|
+
settle(null)
|
|
716
|
+
return { kind: 'failed', failure: 'upstream_error', detail: 'empty stream' }
|
|
717
|
+
}
|
|
718
|
+
parser = new DirectSseParser(idPrefix)
|
|
719
|
+
const reader = response.body.getReader()
|
|
720
|
+
let ended = false
|
|
721
|
+
while (!ended) {
|
|
722
|
+
const { done, value } = await reader.read()
|
|
723
|
+
if (done) break
|
|
724
|
+
bumpInactivity()
|
|
725
|
+
for (const ev of parser.push(value)) {
|
|
726
|
+
if (ev.type === 'text') onText(ev.text)
|
|
727
|
+
else if (ev.type === 'error') { streamError = ev.message; ended = true }
|
|
728
|
+
else if (ev.type === 'done') ended = true
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
if (ended) await reader.cancel().catch(() => undefined)
|
|
732
|
+
else for (const ev of parser.finish()) {
|
|
733
|
+
if (ev.type === 'text') onText(ev.text)
|
|
734
|
+
else if (ev.type === 'error') streamError = ev.message
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
} catch (error) {
|
|
738
|
+
settle(null)
|
|
739
|
+
if (abort.signal.aborted) return { kind: 'aborted' }
|
|
740
|
+
return { kind: 'failed', failure: 'network_error', detail: error instanceof Error ? error.message.slice(0, 160) : 'stream failed' }
|
|
741
|
+
}
|
|
742
|
+
if (parser.usage) {
|
|
743
|
+
const usd = parser.usage.costUsd ?? (parser.usage.input * price.inUsdPerM + parser.usage.output * price.outUsdPerM) / 1e6
|
|
744
|
+
usageTotals.input += parser.usage.input
|
|
745
|
+
usageTotals.output += parser.usage.output
|
|
746
|
+
usageTotals.usd += usd
|
|
747
|
+
usageTotals.measured = true
|
|
748
|
+
settle({
|
|
749
|
+
input: parser.usage.input,
|
|
750
|
+
output: parser.usage.output,
|
|
751
|
+
...(parser.usage.costUsd !== undefined ? { costUsd: parser.usage.costUsd } : {}),
|
|
752
|
+
...((response.headers.get('x-generation-id') || parser.generationId) ? { generationId: response.headers.get('x-generation-id') || parser.generationId! } : {}),
|
|
753
|
+
})
|
|
754
|
+
} else {
|
|
755
|
+
settle(null)
|
|
756
|
+
}
|
|
757
|
+
const roundText = fullText.slice(roundStart)
|
|
758
|
+
if (streamError) {
|
|
759
|
+
return parser.tools.error()
|
|
760
|
+
? { kind: 'failed', failure: 'tool_overflow', detail: streamError }
|
|
761
|
+
: { kind: 'failed', failure: 'upstream_error', detail: streamError }
|
|
762
|
+
}
|
|
763
|
+
const calls = parser.tools.calls()
|
|
764
|
+
if (calls.length > 0) return { kind: 'tools', calls, text: roundText }
|
|
765
|
+
return roundText.trim() ? { kind: 'text' } : { kind: 'empty' }
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
bumpInactivity()
|
|
769
|
+
wallTimer = setTimeout(() => { timedOut = true; abort.abort() }, WALL_MAX_MS)
|
|
770
|
+
|
|
771
|
+
try {
|
|
772
|
+
for (;;) {
|
|
773
|
+
const roundStart = fullText.length
|
|
774
|
+
const round = await runPost()
|
|
775
|
+
if (round.kind === 'aborted') {
|
|
776
|
+
await finalizeError(
|
|
777
|
+
timedOut ? 'direct-bridge: [network_error] timed out' : 'direct-bridge: [cancelled] request aborted',
|
|
778
|
+
timedOut ? lensLine('network_error') : DIRECT_COPY.cancelled,
|
|
779
|
+
)
|
|
780
|
+
return sid
|
|
781
|
+
}
|
|
782
|
+
if (round.kind === 'failed') {
|
|
783
|
+
await finalizeError(`direct-bridge: [${round.failure}] ${round.detail}`, failureLine(round.failure))
|
|
784
|
+
return sid
|
|
785
|
+
}
|
|
786
|
+
if (callbacks.onTrail && (round.kind === 'text' || round.kind === 'tools')) {
|
|
787
|
+
const roundText = fullText.slice(roundStart)
|
|
788
|
+
if (roundText.trim()) trail({ kind: 'prose', text: roundText })
|
|
789
|
+
}
|
|
790
|
+
if (round.kind === 'text') { await finalizeDone(); return sid }
|
|
791
|
+
if (round.kind === 'empty') {
|
|
792
|
+
await finalizeError('direct-bridge: [empty] no answer', DIRECT_COPY.empty)
|
|
793
|
+
return sid
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// D7 backstop: tools were not offered, so a call is never run. The text (if any) stands.
|
|
797
|
+
if (toolDefs.length === 0 || !sendTools) {
|
|
798
|
+
if (round.text.trim()) { await finalizeDone(); return sid }
|
|
799
|
+
await finalizeError('direct-bridge: [empty] tool call without tools', DIRECT_COPY.empty)
|
|
800
|
+
return sid
|
|
801
|
+
}
|
|
802
|
+
// Post 5 is the closing fetch: its calls are not run and there is never a sixth post.
|
|
803
|
+
if (postCount >= DIRECT_MAX_POSTS) {
|
|
804
|
+
await finalizeError('direct-bridge: tool round cap reached without a final answer.', DIRECT_COPY.toolCap)
|
|
805
|
+
return sid
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
messages.push({ role: 'assistant', content: round.text.trim() ? round.text : null, tool_calls: round.calls })
|
|
809
|
+
for (const call of round.calls) {
|
|
810
|
+
if (finalized || abort.signal.aborted) break
|
|
811
|
+
const name = call.function.name
|
|
812
|
+
const args = parseToolArguments(call.function.arguments)
|
|
813
|
+
// executeOllamaTool answers any name outside its closed allowlist with `unknown tool`.
|
|
814
|
+
callbacks.onToolStatus?.(name)
|
|
815
|
+
if (callbacks.onTrail) trail(ollamaTrailStep(name, args))
|
|
816
|
+
toolHeartbeat = setInterval(bumpInactivity, TOOL_HEARTBEAT_MS)
|
|
817
|
+
let result: string
|
|
818
|
+
try {
|
|
819
|
+
result = await executeOllamaTool(name, args, abort.signal)
|
|
820
|
+
} finally {
|
|
821
|
+
if (toolHeartbeat) { clearInterval(toolHeartbeat); toolHeartbeat = undefined }
|
|
822
|
+
}
|
|
823
|
+
if (result === 'aborted' || abort.signal.aborted) {
|
|
824
|
+
await finalizeError('direct-bridge: tool call aborted.', timedOut ? lensLine('network_error') : DIRECT_COPY.cancelled)
|
|
825
|
+
return sid
|
|
826
|
+
}
|
|
827
|
+
if (callbacks.onTrail) trail(ollamaTrailOutcome(result))
|
|
828
|
+
messages.push(buildDirectToolMessage(call, result))
|
|
829
|
+
}
|
|
830
|
+
if (finalized) return sid
|
|
831
|
+
if (abort.signal.aborted) {
|
|
832
|
+
await finalizeError('direct-bridge: tool call aborted.', timedOut ? lensLine('network_error') : DIRECT_COPY.cancelled)
|
|
833
|
+
return sid
|
|
834
|
+
}
|
|
835
|
+
bumpWall()
|
|
836
|
+
}
|
|
837
|
+
} catch (error: unknown) {
|
|
838
|
+
const detail = error instanceof Error ? error.message.slice(0, 160) : 'failed'
|
|
839
|
+
await finalizeError(`direct-bridge: [upstream_error] ${detail}`, lensLine('upstream_error'))
|
|
840
|
+
return sid
|
|
841
|
+
} finally {
|
|
842
|
+
clearTimers()
|
|
843
|
+
options?.abortSignal?.removeEventListener('abort', onExternalAbort)
|
|
844
|
+
try { relayTurn.close() } catch { /* best effort */ }
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** The lens line for a refusal kind. */
|
|
849
|
+
function refusalLine(kind: DirectRefusalKind, lensLine: (id: DirectNoticeErrorId) => string): string {
|
|
850
|
+
if (kind === 'lanes_inactive') return lensLine('profile_off')
|
|
851
|
+
if (kind === 'key_missing') return lensLine('profile_removed')
|
|
852
|
+
return lensLine(kind)
|
|
853
|
+
}
|