Skip to content

Commit 165b65a

Browse files
msoginclaude
andauthored
GLOOK-51: stop caching a truncated projects generation as an empty success (#72)
* GLOOK-51: stop caching a truncated projects generation as an empty success The Integrations team's Current Projects section was blank on every reload, while smaller teams on the same report were fine. The model had produced good output. A flat tokenLimit(1500) truncated it mid-object — Integrations has 14 developers, so it clusters into the most projects and the longest JSON — JSON.parse threw, the catch returned [], and service.ts wrote that [] to the cache. Since the lazy top-up only re-runs when the column is NULL, a single truncation blanked the section permanently. The request timings show it: 14,037ms for the failing generation, then 12ms and 6ms for every reload after. Three defects, each necessary for the outcome: - The budget was a flat 1500 regardless of team size. It now scales (2000 + 400/member, capped at 8000). For comparison projects/insights.ts runs at 12000+ for similar clustering work. - finish_reason was never read here, so truncation was reported as a parse error — which reads like a model or prompt problem rather than a budget one, and sent the investigation to the wrong place. projects/insights.ts and projects/untracked.ts already do this check; this module never got it. - Worst of the three: a failure was indistinguishable from a legitimate "this team has no projects", because both returned []. Unusable output now raises TeamProjectsUnusableError, and both call sites in service.ts leave the column NULL so the next load retries. A genuine empty result is still cached, so the page does not pay for the LLM on every view. PROMPT_VERSION is bumped to v5-budget. It is part of the cache key (report_id + team_name + prompt_version), so every already-poisoned row is invalidated on deploy and existing reports recover without DB surgery. 127 suites / 1250 tests pass; npm run build clean. The existing generator suite had no coverage of the parse-error path at all, which is why this could regress silently; the new suite pins truncation, unparseable-but-not-truncated, the genuine-empty case staying cacheable, and the budget actually being requested. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(test): drop the dotAll regex flag — tsconfig targets ES2017 CI's Type-check step failed with TS1501: the `s` flag needs es2018. Replaced with an explicit [\s\S]* , which the pattern didn't need a flag for anyway. Worth recording why this reached CI: `npm run build` type-checks app code but not test files, and I had been treating a clean build as covering `tsc`. It does not. `npx tsc --noEmit` — the exact command CI runs — completes fine when nothing else is competing for memory; my earlier runs were OOM-killed while a container build was in flight, and I substituted the build instead of retrying it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * GLOOK-51: actually invalidate the poisoned rows Review found the recovery half of the previous commit did not work: the teams this ticket was filed for would have stayed blank after merge. prompt_version is in the SELECT key but NOT in the table's unique key, which is `(report_id, team_name)` only. So a bump makes the read miss and the code regenerate, but the upsert lands on the same physical row. TeamPulseCard mounts without withProjects, so projectsForDb is null, and `COALESCE(VALUES(projects), projects)` then PRESERVES the poisoned '[]' while stamping prompt_version = 'v5-budget'. The row now has a truthy projects, so the lazy top-up guard never fires again: permanently blank, under a version claiming to have fixed it. The same COALESCE also defeated the `projectsForDb = null` half of the fix on any pre-existing row. The upsert now takes the incoming value verbatim — including NULL — when the version differs, and keeps COALESCE within a version so the documented concurrency race is still protected. `VALUES(col)` is rewritten to `excluded.col` by translateSQL, so it works on both drivers. Also from review: - Two comments of mine were factually wrong and are corrected, because these comments are the incident record for the next reader. projects/insights.ts and projects/untracked.ts do NOT do the same finish_reason check: insights reads it only to classify a message after a parse has already failed, and untracked logs it and continues into the parse. Neither refuses a truncated response up front. And insights records 12000 as the value that TRUNCATED for it, now running 32000 — so it argues the 8000 ceiling is conservative, not that it is adequate. The comment says that now. - The finish_reason branch was dead on Bedrock: the adapter never copied stop_reason, so finishReason was always 'unknown' and a Bedrock truncation fell through to the parse and got reported as unparseable — the exact misdiagnosis this change claims to remove. Mapped in the adapter, which also repairs the existing reads in insights.ts and untracked.ts. llm-mock had the same gap. - A third path still cached an unusable empty: if the model returns projects but every one fails the team filter, out is [] and that was persisted as a success. Following what insights.ts already settled for this exact tension, it is served but NOT stored — no throw, so no paid retry loop. generateTeamProjects now returns { projects, cacheable }. - Both catch sites were untyped, so a TypeError in the enrichment loop or a DB outage was classified as transient and retried on every card expand. They now gate on `err instanceof TeamProjectsUnusableError` and let anything else propagate to the route's 500, which also gives the exported error type its only consumer. - Nits: the budget is hoisted so the logged value is provably the requested one; the impossible Math.max guard and the test defending it are gone; the budget assertion is a literal 7600 rather than a comparison of the implementation to itself; and the test mock now mirrors tokenLimit()'s real provider-dependent shape instead of only max_tokens. Tests: the review's point that nothing would catch the storage behaviour was correct — every test targeted projects.ts. The new cache suite drives the REAL SQLite driver, because a mocked DB would assert that null was passed and pass while the row still ended up '[]'. It includes a negative control executing the pre-fix SQL, which demonstrates the poison surviving alongside the new version stamp. 128 suites / 1258 tests; tsc --noEmit and npm run build both clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: msogin <msogin@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 90bc476 commit 165b65a

7 files changed

Lines changed: 432 additions & 33 deletions

File tree

‎src/lib/__tests__/unit/team-projects-generator.test.ts‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ describe('generateTeamProjects', () => {
3737
beforeEach(() => mockGetLLMClient.mockReset());
3838

3939
it('short-circuits to [] when both commits and jira are empty (no LLM call)', async () => {
40-
const out = await generateTeamProjects({ team_members: ['alice'], commits: [], jira_issues: [], in_flight_prs: [], in_flight_branches: [] });
40+
const { projects: out } = await generateTeamProjects({ team_members: ['alice'], commits: [], jira_issues: [], in_flight_prs: [], in_flight_branches: [] });
4141
expect(out).toEqual([]);
4242
expect(mockGetLLMClient).not.toHaveBeenCalled();
4343
});
@@ -50,7 +50,7 @@ describe('generateTeamProjects', () => {
5050
}));
5151
mockGetLLMClient.mockResolvedValueOnce(client);
5252

53-
const out = await generateTeamProjects({
53+
const { projects: out } = await generateTeamProjects({
5454
team_members: ['alice'],
5555
commits: [],
5656
jira_issues: [],
@@ -70,7 +70,7 @@ describe('generateTeamProjects', () => {
7070
last_activity: '2026-05-21T11:00:00Z' },
7171
],
7272
})));
73-
const out = await generateTeamProjects(baseInput());
73+
const { projects: out } = await generateTeamProjects(baseInput());
7474
expect(out).toHaveLength(1);
7575
expect(out[0].name).toBe('P1');
7676
expect(out[0].developers).toEqual(['alice', 'bob']);
@@ -85,7 +85,7 @@ describe('generateTeamProjects', () => {
8585
last_activity: '2026-05-21T11:00:00Z' },
8686
],
8787
})));
88-
const out = await generateTeamProjects(baseInput());
88+
const { projects: out } = await generateTeamProjects(baseInput());
8989
expect(out[0].developers).toEqual(['alice', 'bob']);
9090
});
9191

@@ -100,7 +100,7 @@ describe('generateTeamProjects', () => {
100100
last_activity: '2026-05-20T10:00:00Z' },
101101
],
102102
})));
103-
const out = await generateTeamProjects(baseInput());
103+
const { projects: out } = await generateTeamProjects(baseInput());
104104
expect(out).toHaveLength(1);
105105
expect(out[0].name).toBe('OK');
106106
});
@@ -114,7 +114,7 @@ describe('generateTeamProjects', () => {
114114
last_activity: '2020-01-01T00:00:00Z' },
115115
],
116116
})));
117-
const out = await generateTeamProjects(baseInput());
117+
const { projects: out } = await generateTeamProjects(baseInput());
118118
// Max committed_at across the 2 baseInput commits is bob's '2026-05-21T11:00:00Z'
119119
expect(out[0].last_activity).toBe('2026-05-21T11:00:00Z');
120120
});
@@ -127,7 +127,7 @@ describe('generateTeamProjects', () => {
127127
last_activity: '2026-05-20T10:00:00Z' },
128128
],
129129
}) + '\n```'));
130-
const out = await generateTeamProjects(baseInput());
130+
const { projects: out } = await generateTeamProjects(baseInput());
131131
expect(out).toHaveLength(1);
132132
});
133133

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import {
2+
generateTeamProjects,
3+
projectsTokenBudget,
4+
TeamProjectsUnusableError,
5+
} from '@/lib/team-pulse/projects';
6+
7+
/**
8+
* GLOOK-51. The Integrations team (14 developers) showed an empty Current
9+
* Projects section on every reload. The model HAD produced good output — it was
10+
* truncated mid-object by a flat tokenLimit(1500), JSON.parse threw, the catch
11+
* returned [], and service.ts cached that [] as a success. Because the lazy
12+
* top-up only re-runs when the column is NULL, one truncation blanked the
13+
* section permanently.
14+
*
15+
* The contract: a failure must be distinguishable from "this team genuinely has
16+
* no projects", because only the latter may be cached.
17+
*/
18+
19+
const mockCreate = jest.fn();
20+
jest.mock('@/lib/llm-provider', () => ({
21+
getLLMClient: async () => ({ chat: { completions: { create: mockCreate } } }),
22+
LLM_MODEL: 'test-model',
23+
extraBodyProps: () => ({}),
24+
// Mirrors the real tokenLimit(): the openai provider gets
25+
// max_completion_tokens, everything else max_tokens. Hard-coding max_tokens
26+
// here would assert against a shape production may not use.
27+
tokenLimit: (n: number) => ({ max_tokens: n, max_completion_tokens: n }),
28+
promptTag: () => ({}),
29+
samplingParams: () => ({}),
30+
}));
31+
jest.mock('@/lib/prompt-loader', () => ({ loadPrompt: () => 'SYSTEM PROMPT' }));
32+
33+
function input(teamSize: number) {
34+
const team_members = Array.from({ length: teamSize }, (_, i) => `dev${i}`);
35+
return {
36+
team_members,
37+
commits: [{ github_login: 'dev0', committed_at: '2026-09-10T00:00:00Z', commit_message: 'x', repo: 'r' }],
38+
jira_issues: [],
39+
in_flight_prs: [],
40+
in_flight_branches: [],
41+
} as any;
42+
}
43+
44+
function reply(content: string, finish_reason = 'stop') {
45+
return { choices: [{ finish_reason, message: { content } }] };
46+
}
47+
48+
beforeEach(() => mockCreate.mockReset());
49+
50+
describe('projectsTokenBudget', () => {
51+
it('scales with team size instead of a flat cap', () => {
52+
// The flat 1500 is what truncated a 14-developer team.
53+
expect(projectsTokenBudget(14)).toBeGreaterThan(1500);
54+
expect(projectsTokenBudget(14)).toBeGreaterThan(projectsTokenBudget(3));
55+
});
56+
57+
it('is bounded, and non-zero for an empty roster', () => {
58+
expect(projectsTokenBudget(1000)).toBeLessThanOrEqual(8000);
59+
expect(projectsTokenBudget(0)).toBeGreaterThan(0);
60+
});
61+
});
62+
63+
describe('an unusable response RAISES rather than returning []', () => {
64+
it('raises on truncation, naming it as truncation', async () => {
65+
// The exact incident shape: good JSON, cut off mid-object.
66+
mockCreate.mockResolvedValue(reply(
67+
'{"projects":[{"name":"Datadog On-Call Migration","estimated_prs":20,',
68+
'length',
69+
));
70+
await expect(generateTeamProjects(input(14), 'Integrations'))
71+
.rejects.toThrow(TeamProjectsUnusableError);
72+
await expect(generateTeamProjects(input(14), 'Integrations'))
73+
.rejects.toThrow(/truncated[\s\S]*finish_reason=length/);
74+
});
75+
76+
it('raises on unparseable output that was NOT truncated', async () => {
77+
mockCreate.mockResolvedValue(reply('not json at all'));
78+
await expect(generateTeamProjects(input(5), 'Data'))
79+
.rejects.toThrow(/unparseable/);
80+
});
81+
82+
it('reports truncation distinctly from malformed JSON', async () => {
83+
// Both used to log "parse error", which sent the investigation looking at
84+
// the model instead of the token budget.
85+
mockCreate.mockResolvedValue(reply('{"projects":[{"name":"x",', 'length'));
86+
const truncated = await generateTeamProjects(input(14), 'T').catch((e) => e.message);
87+
mockCreate.mockResolvedValue(reply('garbage'));
88+
const malformed = await generateTeamProjects(input(14), 'T').catch((e) => e.message);
89+
expect(truncated).toMatch(/truncated/);
90+
expect(malformed).not.toMatch(/truncated/);
91+
});
92+
});
93+
94+
describe('a genuine empty result is still a success', () => {
95+
it('returns [] without raising when the model reports no projects', async () => {
96+
// Must stay cacheable — otherwise every load pays for the LLM.
97+
mockCreate.mockResolvedValue(reply('{"projects":[]}'));
98+
await expect(generateTeamProjects(input(5), 'Small'))
99+
.resolves.toEqual({ projects: [], cacheable: true });
100+
});
101+
102+
it('short-circuits with no LLM call when there is nothing to cluster', async () => {
103+
const empty = { team_members: ['a'], commits: [], jira_issues: [], in_flight_prs: [], in_flight_branches: [] } as any;
104+
await expect(generateTeamProjects(empty, 'Quiet'))
105+
.resolves.toEqual({ projects: [], cacheable: true });
106+
expect(mockCreate).not.toHaveBeenCalled();
107+
});
108+
});
109+
110+
describe('a usable response is still parsed normally', () => {
111+
it('keeps projects whose developers are on the team', async () => {
112+
mockCreate.mockResolvedValue(reply(JSON.stringify({
113+
projects: [
114+
{ name: 'Real', summary: 's', developers: ['dev0'], jira_count: 1, estimated_commits: 5, estimated_prs: 2 },
115+
{ name: 'Nobody', summary: 's', developers: ['stranger'], jira_count: 0 },
116+
],
117+
})));
118+
const { projects: out } = await generateTeamProjects(input(3), 'T');
119+
expect(out).toHaveLength(1);
120+
expect(out[0].name).toBe('Real');
121+
// last_activity is overridden from real commit data.
122+
expect(out[0].last_activity).toBe('2026-09-10T00:00:00Z');
123+
});
124+
125+
it('requests a literal budget for the incident team, not just "whatever the fn returns"', async () => {
126+
// Asserted as a literal on purpose: comparing against
127+
// projectsTokenBudget(14) compares the implementation to itself, and a
128+
// regression to the flat 1500 that caused GLOOK-51 would still pass.
129+
mockCreate.mockResolvedValue(reply('{"projects":[]}'));
130+
await generateTeamProjects(input(14), 'Integrations');
131+
expect(projectsTokenBudget(14)).toBe(7600);
132+
expect(mockCreate.mock.calls[0][0].max_tokens).toBe(7600);
133+
expect(mockCreate.mock.calls[0][0].max_completion_tokens).toBe(7600);
134+
});
135+
});
136+
137+
describe('an empty that the validator produced is served but NOT cached', () => {
138+
it('marks cacheable=false when the model named nobody on the team', async () => {
139+
// The model returned projects; every developer failed the teamSet filter
140+
// (e.g. display names instead of logins). Caching this would blank the
141+
// section permanently — GLOOK-51 arriving by a second route. Deliberately
142+
// not a throw: the empty may be correct, and raising buys a paid retry loop.
143+
mockCreate.mockResolvedValue(reply(JSON.stringify({
144+
projects: [{ name: 'Real work', summary: 's', developers: ['Some Person'] }],
145+
})));
146+
const res = await generateTeamProjects(input(3), 'T');
147+
expect(res.projects).toEqual([]);
148+
expect(res.cacheable).toBe(false);
149+
});
150+
151+
it('still marks a genuinely empty model response as cacheable', async () => {
152+
mockCreate.mockResolvedValue(reply('{"projects":[]}'));
153+
const res = await generateTeamProjects(input(3), 'T');
154+
expect(res).toEqual({ projects: [], cacheable: true });
155+
});
156+
});
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* GLOOK-51 storage behaviour, driven against the REAL SQLite driver.
3+
*
4+
* A mocked-DB test is not sufficient here and would have shipped the bug: the
5+
* first fix bumped PROMPT_VERSION believing that invalidated poisoned rows, and
6+
* a mock asserting "null was passed for projects" would have passed while the
7+
* row still ended up '[]'. prompt_version is in the SELECT key but NOT in the
8+
* unique key — `UNIQUE (report_id, team_name)` — so the upsert lands on the
9+
* same physical row, where `COALESCE(VALUES(projects), projects)` preserves the
10+
* old value. Only executing the upsert shows that.
11+
*/
12+
import fs from 'fs';
13+
import os from 'os';
14+
import path from 'path';
15+
16+
const UPSERT = `INSERT INTO team_pulse_summaries (report_id, team_name, org, summary_text, health_json, projects, prompt_version)
17+
VALUES (?, ?, ?, ?, ?, ?, ?)
18+
ON DUPLICATE KEY UPDATE summary_text = VALUES(summary_text), health_json = VALUES(health_json), projects = CASE WHEN VALUES(prompt_version) <> prompt_version THEN VALUES(projects) ELSE COALESCE(VALUES(projects), projects) END, prompt_version = VALUES(prompt_version), generated_at = NOW()`;
19+
20+
describe('team_pulse_summaries upsert — poisoned-row recovery', () => {
21+
const prevSqlitePath = process.env.SQLITE_PATH;
22+
const prevDbType = process.env.DB_TYPE;
23+
let tmpDir: string;
24+
let db: { execute: <T = any>(sql: string, params?: any[]) => Promise<[T[], any]> };
25+
26+
beforeAll(async () => {
27+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'glooker-glook51-'));
28+
process.env.SQLITE_PATH = path.join(tmpDir, 'test.db');
29+
process.env.DB_TYPE = 'sqlite';
30+
const { createSQLiteDB } = await import('@/lib/db/sqlite');
31+
db = createSQLiteDB();
32+
await db.execute(
33+
`INSERT INTO reports (id, org, period_days, status) VALUES (?, ?, ?, ?)`,
34+
['r1', 'Smartling', 14, 'completed'],
35+
);
36+
});
37+
38+
afterAll(() => {
39+
if (prevSqlitePath === undefined) delete process.env.SQLITE_PATH;
40+
else process.env.SQLITE_PATH = prevSqlitePath;
41+
if (prevDbType === undefined) delete process.env.DB_TYPE;
42+
else process.env.DB_TYPE = prevDbType;
43+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* already gone */ }
44+
});
45+
46+
const upsert = (team: string, projects: string | null, version: string) =>
47+
db.execute(UPSERT, ['r1', team, 'Smartling', 'summary', '{}', projects, version]);
48+
49+
const readRow = async (team: string) => {
50+
const [rows] = await db.execute(
51+
`SELECT projects, prompt_version FROM team_pulse_summaries WHERE report_id = ? AND team_name = ?`,
52+
['r1', team],
53+
) as [any[], any];
54+
return rows[0];
55+
};
56+
57+
it('confirms the premise: prompt_version is NOT part of the unique key', async () => {
58+
// If it were, the second insert would create a second row and the whole
59+
// invalidation story would work as originally claimed.
60+
await upsert('KeyProbe', '[]', 'v4-inflight');
61+
await upsert('KeyProbe', null, 'v5-budget');
62+
const [rows] = await db.execute(
63+
`SELECT COUNT(*) AS n FROM team_pulse_summaries WHERE report_id = ? AND team_name = ?`,
64+
['r1', 'KeyProbe'],
65+
) as [any[], any];
66+
expect(Number(rows[0].n)).toBe(1);
67+
});
68+
69+
it('a version change REPLACES a poisoned projects value with NULL', async () => {
70+
// The GLOOK-51 recovery. Without the CASE, COALESCE keeps '[]' here and the
71+
// lazy top-up guard (row.projects === null) never fires again.
72+
await upsert('Integrations', '[]', 'v4-inflight');
73+
expect((await readRow('Integrations')).projects).toBe('[]');
74+
75+
await upsert('Integrations', null, 'v5-budget');
76+
const row = await readRow('Integrations');
77+
expect(row.projects).toBeNull();
78+
expect(row.prompt_version).toBe('v5-budget');
79+
});
80+
81+
it('still protects the concurrency race WITHIN a version', async () => {
82+
// Documented at service.ts: a no-withProjects write must not clobber a
83+
// successfully generated value back to NULL. Deleting the COALESCE to fix
84+
// the case above would reintroduce exactly this.
85+
await upsert('Data', JSON.stringify([{ name: 'Real' }]), 'v5-budget');
86+
await upsert('Data', null, 'v5-budget');
87+
expect(await readRow('Data').then(r => r.projects)).toBe('[{"name":"Real"}]');
88+
});
89+
90+
it('a version change still carries a good new value through', async () => {
91+
await upsert('GDN', '[]', 'v4-inflight');
92+
await upsert('GDN', JSON.stringify([{ name: 'Fresh' }]), 'v5-budget');
93+
expect(await readRow('GDN').then(r => r.projects)).toBe('[{"name":"Fresh"}]');
94+
});
95+
96+
it('NEGATIVE CONTROL: the pre-fix upsert preserves the poison, proving the CASE is load-bearing', async () => {
97+
// This is the SQL that shipped in the first GLOOK-51 attempt. Executing it
98+
// shows the bump was a no-op that additionally mis-stamped the row, which
99+
// is what the review found and what a mocked DB could not have shown.
100+
const OLD = UPSERT.replace(
101+
'projects = CASE WHEN VALUES(prompt_version) <> prompt_version THEN VALUES(projects) ELSE COALESCE(VALUES(projects), projects) END',
102+
'projects = COALESCE(VALUES(projects), projects)',
103+
);
104+
await upsert('OldSql', '[]', 'v4-inflight');
105+
await db.execute(OLD, ['r1', 'OldSql', 'Smartling', 'summary', '{}', null, 'v5-budget']);
106+
const row = await readRow('OldSql');
107+
expect(row.projects).toBe('[]'); // still poisoned
108+
expect(row.prompt_version).toBe('v5-budget'); // but now claims to be fixed
109+
});
110+
111+
it('a genuine empty result is still persisted, so the page does not re-pay', async () => {
112+
await upsert('Quiet', '[]', 'v5-budget');
113+
const row = await readRow('Quiet');
114+
expect(row.projects).toBe('[]');
115+
});
116+
});

‎src/lib/bedrock-adapter.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,16 @@ export function createBedrockClient() {
7070
return {
7171
choices: [
7272
{
73+
// Bedrock reports truncation as stop_reason: 'max_tokens'.
74+
// Without this mapping every caller sees finish_reason
75+
// undefined, so truncation checks are dead on this provider and
76+
// a cut-off response gets misreported as malformed output
77+
// (GLOOK-51). Also repairs the existing reads in
78+
// projects/insights.ts and projects/untracked.ts.
79+
finish_reason:
80+
decoded.stop_reason === 'max_tokens' ? 'length'
81+
: decoded.stop_reason === 'end_turn' ? 'stop'
82+
: (decoded.stop_reason ?? 'stop'),
7383
message: {
7484
role: 'assistant',
7585
content: decoded.content?.[0]?.text ?? '',

‎src/lib/llm-mock.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ export function createMockLLMClient() {
8787
return {
8888
choices: [
8989
{
90+
// Mock the field too, so a caller that checks finish_reason
91+
// behaves the same in mock mode as against a real provider.
92+
finish_reason: 'stop',
9093
message: {
9194
role: 'assistant',
9295
content,

0 commit comments

Comments
 (0)