forked from nexu-io/open-design
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathopencode-log.ts
More file actions
177 lines (163 loc) · 7.16 KB
/
Copy pathopencode-log.ts
File metadata and controls
177 lines (163 loc) · 7.16 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
// OpenCode swallows provider failures in headless `run --format json` mode:
// on a 429 usage-limit (and similar), it marks the error retryable, retries
// silently, and emits NOTHING on stdout/stderr — so the daemon only sees an
// inactivity-watchdog timeout with no reason. The real error is recorded
// only in OpenCode's own session log (`service=llm … error={…}`). This
// module recovers that signal so the chat UI can show "usage limit reached"
// instead of a bare timeout. OpenCode-specific by design; see issue #982.
import { readdirSync, readFileSync, statSync } from 'node:fs';
import path from 'node:path';
import { classifyAgentServiceFailure, type AgentServiceFailureCode } from './auth.js';
export interface OpenCodeServiceFailure {
code: AgentServiceFailureCode;
message: string;
retryable: boolean;
statusCode: number | null;
}
// OpenCode resolves its data dir as `$XDG_DATA_HOME/opencode` (when set) or
// `$HOME/.local/share/opencode`, with session logs under `log/`. Mirror that
// so we read the same files the spawned CLI wrote. Null when neither var is
// set (we have no basis to guess a path).
export function resolveOpenCodeLogDir(
env: Record<string, string | undefined>,
): string | null {
const xdg = typeof env.XDG_DATA_HOME === 'string' ? env.XDG_DATA_HOME.trim() : '';
const home = typeof env.HOME === 'string' ? env.HOME.trim() : '';
const base = xdg || (home ? path.join(home, '.local', 'share') : '');
if (!base) return null;
return path.join(base, 'opencode', 'log');
}
// Read the tail of OpenCode's most recent session log. Filenames are
// `<ISO-like-timestamp>.log`, so a lexicographic sort orders them by recency.
// `since` (when provided) binds the lookup to the current run: a file last
// written before the run started can only belong to an earlier session, so
// it is skipped rather than risk surfacing a stale provider error for this
// run. (This does not disambiguate two OpenCode runs writing into the same
// HOME concurrently — OpenCode only emits its session id on the stdout
// stream, which is empty in the silent-stall case, so mtime is the only
// run-binding signal available here.) The 2 MB tail comfortably holds the
// final error frame even though
// OpenCode embeds the entire request body (system prompt + tool schemas) in
// each `service=llm` line. Synchronous on purpose: the only callers are the
// (non-async) run close handler and the inactivity watchdog, once per failed
// OpenCode run. Returns null on any fs error (no dir yet, perms).
export function readLatestOpenCodeLogTail(
logDir: string,
options: { maxBytes?: number; since?: number } = {},
): string | null {
const { maxBytes = 2_000_000, since } = options;
let names: string[];
try {
names = readdirSync(logDir).filter((name) => name.endsWith('.log'));
} catch {
return null;
}
if (names.length === 0) return null;
names.sort().reverse(); // newest filename first
for (const name of names) {
const full = path.join(logDir, name);
if (since != null) {
try {
if (statSync(full).mtimeMs < since) continue;
} catch {
continue;
}
}
try {
const buf = readFileSync(full, 'utf8');
return buf.length > maxBytes ? buf.slice(-maxBytes) : buf;
} catch {
continue;
}
}
return null;
}
// Only treat a `"message":"…"` value as the failure reason when it reads
// like a service error. The embedded request body uses `"content":` for
// prompt text, but tool schemas and user prompts could still contain a
// stray `"message"` key, so this keyword gate keeps unrelated payload text
// from masquerading as the error.
const SERVICE_ERROR_MESSAGE_RE =
/usage limit|rate[ _-]?limit|quota|limit reached|insufficient|credit|balance|overloaded|unavailable|unauthor|authenticat|invalid[ _-]?(?:api[ _-]?)?key|api key|\/login|exhaust|too many requests/i;
const HARD_QUOTA_MESSAGE_RE =
/\b(session limit|usage limit|limit reached|quota|billing (?:hard )?limit|insufficient[ _-]?(?:quota|credit|funds)|exceeded your current quota)\b/i;
function pickServiceErrorMessage(line: string): string | null {
const re = /"message":"((?:[^"\\]|\\.)*)"/g;
let fallback: string | null = null;
let match: RegExpExecArray | null;
while ((match = re.exec(line)) !== null) {
let value: string;
try {
value = JSON.parse(`"${match[1]}"`);
} catch {
value = match[1]!;
}
value = value.trim();
if (SERVICE_ERROR_MESSAGE_RE.test(value)) return value;
if (!fallback) fallback = value;
}
return fallback && SERVICE_ERROR_MESSAGE_RE.test(fallback) ? fallback : null;
}
function codeFromStatus(statusCode: number): AgentServiceFailureCode | null {
if (statusCode === 401 || statusCode === 403) return 'AGENT_AUTH_REQUIRED';
if (statusCode === 429) return 'RATE_LIMITED';
if (statusCode >= 500 && statusCode <= 599) return 'UPSTREAM_UNAVAILABLE';
return null;
}
function defaultMessageForCode(code: AgentServiceFailureCode): string {
switch (code) {
case 'AGENT_AUTH_REQUIRED':
return 'OpenCode could not authenticate with the model provider.';
case 'RATE_LIMITED':
return 'OpenCode hit a provider usage or rate limit.';
case 'UPSTREAM_UNAVAILABLE':
return "OpenCode's model provider is temporarily unavailable.";
}
}
// Classify the latest `service=llm` provider error in an OpenCode log tail.
// We scope to that single line so the huge request body of *other* lines
// can't leak in, key the classification on the unambiguous HTTP `statusCode`
// first, and fall back to keyword matching the extracted message only.
export function extractOpenCodeServiceFailure(
logTail: string,
): OpenCodeServiceFailure | null {
if (!logTail || !logTail.trim()) return null;
const lines = logTail.split(/\r?\n/);
let line: string | null = null;
for (let i = lines.length - 1; i >= 0; i -= 1) {
const candidate = lines[i]!;
if (
candidate.includes('service=llm') &&
/\bERROR\b/.test(candidate) &&
candidate.includes('error=')
) {
line = candidate;
break;
}
}
if (!line) return null;
const statusMatch = /"statusCode":\s*(\d{3})/.exec(line);
const statusCode = statusMatch ? Number(statusMatch[1]) : null;
const message = pickServiceErrorMessage(line);
let code: AgentServiceFailureCode | null =
statusCode != null ? codeFromStatus(statusCode) : null;
if (!code && message) code = classifyAgentServiceFailure(message);
if (!code) return null;
const retryable = code === 'UPSTREAM_UNAVAILABLE' || (
code === 'RATE_LIMITED' && !HARD_QUOTA_MESSAGE_RE.test(message ?? '')
);
return { code, message: message || defaultMessageForCode(code), retryable, statusCode };
}
// Convenience for the run close handler / inactivity watchdog: resolve the
// log dir from the spawned agent's env, read the newest log tail (bound to
// the current run via `since`), and classify it.
export function readOpenCodeServiceFailure(
env: Record<string, string | undefined>,
options: { since?: number } = {},
): OpenCodeServiceFailure | null {
const logDir = resolveOpenCodeLogDir(env);
if (!logDir) return null;
const tail = readLatestOpenCodeLogTail(logDir, options);
if (!tail) return null;
return extractOpenCodeServiceFailure(tail);
}