@luanpdd/kit-mcp 1.35.0 → 1.36.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/bin/cli.js +2 -2
- package/bin/mcp.js +6 -6
- package/bin/ui.js +74 -74
- package/gates/ai-prompt-stability.md +120 -120
- package/gates/budget-description.md +68 -68
- package/gates/confidence.md +29 -29
- package/gates/dependency-check.md +33 -33
- package/gates/dept-cycle-prevention.md +179 -179
- package/gates/golden-signals-coverage.md +133 -133
- package/gates/legacy-refactor-safety.md +178 -178
- package/gates/multi-tenant-rls-coverage.md +102 -102
- package/gates/no-personal-uuid.md +72 -72
- package/gates/obs-agents-mcp-supabase.md +86 -86
- package/gates/obs-skills-frontmatter.md +76 -76
- package/gates/observability-coverage.md +151 -151
- package/gates/omm-no-regression.md +83 -83
- package/gates/postmortem-template-required.md +127 -127
- package/gates/prr-checklist-coverage.md +128 -128
- package/gates/regression.md +32 -32
- package/gates/release-pipeline-policy.md +132 -132
- package/gates/secrets-scan.md +33 -33
- package/gates/service-role-not-in-user-facing.md +113 -113
- package/gates/skill-must-include.md +71 -71
- package/gates/sync-idempotent.md +62 -62
- package/gates/verify-phase-goal.md +34 -34
- package/kit/agents/designer-ui.md +216 -216
- package/kit/agents/workflow-generator.md +537 -167
- package/kit/commands/adicionar-backlog.md +1 -1
- package/kit/commands/adicionar-fase.md +1 -1
- package/kit/commands/adicionar-tarefa.md +1 -1
- package/kit/commands/auditar-observabilidade.md +103 -103
- package/kit/commands/auditar-toil.md +129 -129
- package/kit/commands/caracterizar-prompt.md +195 -195
- package/kit/commands/criar-workflow.md +158 -158
- package/kit/commands/definir-perfil.md +1 -1
- package/kit/commands/definir-slo.md +108 -108
- package/kit/commands/fio.md +1 -1
- package/kit/commands/golden-signals.md +142 -142
- package/kit/commands/instrumentar-fase.md +200 -200
- package/kit/commands/investigar-producao.md +162 -162
- package/kit/commands/observabilidade.md +118 -118
- package/kit/commands/postmortem.md +179 -179
- package/kit/commands/prr.md +205 -205
- package/kit/commands/publicar-rapido.md +207 -207
- package/kit/commands/risk-budget.md +220 -220
- package/kit/commands/sre.md +230 -230
- package/kit/file-manifest.json +424 -424
- package/kit/framework/references/output-style.md +22 -22
- package/kit/hooks/post-apply-migration.js +199 -199
- package/kit/hooks/sidecar-tool-publisher.js +210 -210
- package/kit/skills/_shared-dados-distribuidos/glossary.md +224 -224
- package/kit/skills/_shared-legacy/glossary.md +389 -389
- package/kit/skills/_shared-multi-tenant/glossary.md +186 -186
- package/kit/skills/_shared-observability/glossary.md +396 -396
- package/kit/skills/_shared-sre/glossary.md +712 -712
- package/kit/skills/_shared-supabase/glossary.md +234 -234
- package/kit/skills/blameless-postmortems/SKILL.md +340 -340
- package/kit/skills/burn-rate-alerting/SKILL.md +258 -258
- package/kit/skills/cascading-failures/SKILL.md +311 -311
- package/kit/skills/core-analysis-loop/SKILL.md +352 -352
- package/kit/skills/distributed-tracing/SKILL.md +362 -362
- package/kit/skills/dynamic-workflow-authoring/SKILL.md +327 -223
- package/kit/skills/eliminating-toil/SKILL.md +243 -243
- package/kit/skills/event-based-slos/SKILL.md +296 -296
- package/kit/skills/four-golden-signals/SKILL.md +314 -314
- package/kit/skills/hermetic-builds/SKILL.md +323 -323
- package/kit/skills/legacy-monster-methods/SKILL.md +444 -444
- package/kit/skills/llm-as-dependency/SKILL.md +436 -436
- package/kit/skills/load-shedding-graceful-degradation/SKILL.md +396 -396
- package/kit/skills/observability-driven-development/SKILL.md +315 -315
- package/kit/skills/observability-maturity-model/SKILL.md +222 -222
- package/kit/skills/opentelemetry-standard/SKILL.md +351 -351
- package/kit/skills/production-readiness-review/SKILL.md +305 -305
- package/kit/skills/release-engineering/SKILL.md +367 -367
- package/kit/skills/retry-strategies/SKILL.md +372 -372
- package/kit/skills/sre-risk-management/SKILL.md +221 -221
- package/kit/skills/structured-events/SKILL.md +265 -265
- package/kit/skills/supabase-cron-queues/SKILL.md +275 -275
- package/kit/skills/supabase-database-functions/SKILL.md +332 -332
- package/kit/skills/supabase-declarative-schema/SKILL.md +183 -183
- package/kit/skills/supabase-pgvector-rag/SKILL.md +253 -253
- package/kit/skills/supabase-postgres-style/SKILL.md +138 -138
- package/kit/skills/supabase-storage/SKILL.md +234 -234
- package/kit/skills/telemetry-pipelines/SKILL.md +259 -259
- package/kit/skills/telemetry-sampling/SKILL.md +256 -256
- package/kit/skills/ui-anti-padroes-ia/SKILL.md +261 -261
- package/kit/skills/ui-contexto-produto/SKILL.md +248 -248
- package/kit/skills/ui-cor-estrategia/SKILL.md +213 -213
- package/kit/skills/ui-critica-auditoria/SKILL.md +260 -260
- package/kit/skills/ui-motion-funcional/SKILL.md +264 -264
- package/kit/skills/ui-ritmo-espacial/SKILL.md +259 -259
- package/kit/skills/ui-tipografia/SKILL.md +211 -211
- package/package.json +1 -1
- package/src/cli/index.js +1114 -1114
- package/src/cli/render.js +194 -194
- package/src/cli/upgrade-check.js +135 -135
- package/src/core/error-redaction.js +76 -76
- package/src/core/failures.js +153 -153
- package/src/core/gate-runner.js +205 -205
- package/src/core/gates.js +82 -82
- package/src/core/logger.js +170 -170
- package/src/core/manifest-verify.js +174 -174
- package/src/core/metrics.js +268 -268
- package/src/core/notify.js +60 -60
- package/src/core/path-safety.js +141 -141
- package/src/core/replays.js +120 -120
- package/src/core/ui.js +185 -185
- package/src/mcp-server/install.js +149 -149
- package/src/mcp-server/roots.js +124 -124
- package/src/ui/auto-spawn.js +113 -113
- package/src/ui/browser.js +78 -78
- package/src/ui/client.js +130 -130
- package/src/ui/events.js +65 -65
- package/src/ui/lockfile.js +191 -191
- package/src/ui/port.js +67 -67
- package/src/ui/server.js +547 -547
- package/src/ui/wrapper.js +129 -129
|
@@ -1,436 +1,436 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: llm-as-dependency
|
|
3
|
-
description: Use ao escrever código que depende de LLM (OpenAI/Anthropic) — adapter pattern + FakeLLMProvider para testes determinísticos sem custo. Modernização 2026 sem precedente em 2004.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# LLM as Dependency (Modernização)
|
|
7
|
-
|
|
8
|
-
## Quando usar
|
|
9
|
-
|
|
10
|
-
LLM carrega esta skill quando user vai escrever ou refatorar código que chama LLM provider em produção. Trigger phrases:
|
|
11
|
-
|
|
12
|
-
- "como testar essa função que chama OpenAI?"
|
|
13
|
-
- "fake do client Anthropic", "mock LLM"
|
|
14
|
-
- "tornar testável código com LLM"
|
|
15
|
-
- "deterministic test mode para LLM"
|
|
16
|
-
- "DI de OpenAI/Anthropic client"
|
|
17
|
-
- "edge function usa LLM, como caracterizar?"
|
|
18
|
-
- "function calling em testes"
|
|
19
|
-
|
|
20
|
-
## Regras absolutas
|
|
21
|
-
|
|
22
|
-
- **LLM é DEPENDÊNCIA EXTERNA igual a DB ou HTTP API.** Mesmas regras: nunca acoplar handler ao SDK específico; sempre via interface; sempre com fake disponível.
|
|
23
|
-
- **Adapter pattern (do skill `legacy-api-only-applications`) é a aplicação correta.** Interface mínima `LLMProvider`; adapter por vendor; fake por testes.
|
|
24
|
-
- **Testes de BUSINESS LOGIC nunca chamam LLM real.** FakeLLMProvider canned responses. LLM real é exclusivamente em characterization tests de PROMPT (skill `ai-prompt-characterization`).
|
|
25
|
-
- **Determinismo via fake.** FakeLLMProvider retorna outputs fixos em ordem. Não há non-determinism em test de business logic.
|
|
26
|
-
- **Token tracking é cross-cutting concern do adapter.** Handler não precisa saber tokens. Adapter loga + opcionalmente reporta para observability.
|
|
27
|
-
- **Tradução de erros: vendor → domain.** Rate limit do OpenAI ≠ Rate limit do Anthropic ≠ Rate limit do Claude API. Adapter traduz para enum próprio (`LLMError = 'rate_limit' | 'timeout' | 'context_too_long' | 'content_filter' | 'auth' | 'unknown'`).
|
|
28
|
-
- **Fake suporta múltiplos modos:** (a) canned responses, (b) function-based responses (`(input) => output`), (c) error injection (`shouldFail: 'rate_limit'`).
|
|
29
|
-
|
|
30
|
-
## Patterns canônicos
|
|
31
|
-
|
|
32
|
-
### Pattern 1: Interface canônica `LLMProvider`
|
|
33
|
-
|
|
34
|
-
```ts
|
|
35
|
-
// Interface mínima — handler não vê SDK específico
|
|
36
|
-
interface LLMProvider {
|
|
37
|
-
generate(input: GenerateInput): Promise<GenerateResult>
|
|
38
|
-
embed?(input: EmbedInput): Promise<EmbedResult> // opcional, só para providers que suportam
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
interface GenerateInput {
|
|
42
|
-
prompt: string // ou messages, dependendo do provider
|
|
43
|
-
systemPrompt?: string
|
|
44
|
-
maxTokens: number
|
|
45
|
-
temperature?: number
|
|
46
|
-
seed?: number // determinismo
|
|
47
|
-
tools?: ToolDefinition[]
|
|
48
|
-
toolChoice?: 'auto' | 'none' | { type: 'tool'; name: string }
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
interface GenerateResult {
|
|
52
|
-
text: string
|
|
53
|
-
finishReason: 'stop' | 'length' | 'tool_use' | 'content_filter'
|
|
54
|
-
toolUses: Array<{ name: string; input: any }>
|
|
55
|
-
inputTokens: number
|
|
56
|
-
outputTokens: number
|
|
57
|
-
modelVersion: string
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
interface EmbedInput {
|
|
61
|
-
texts: string[]
|
|
62
|
-
model?: string // canônico: 'text-embedding-3-small' default; provider mapeia
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
interface EmbedResult {
|
|
66
|
-
embeddings: number[][] // 1 vector por input
|
|
67
|
-
inputTokens: number
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// LLM error canônico (anti-corruption layer)
|
|
71
|
-
type LLMError = 'rate_limit' | 'timeout' | 'context_too_long' | 'content_filter' | 'auth' | 'invalid_request' | 'server_error' | 'unknown'
|
|
72
|
-
class LLMException extends Error {
|
|
73
|
-
constructor(public code: LLMError, message: string, public retryable: boolean) {
|
|
74
|
-
super(message)
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
### Pattern 2: Adapters por vendor
|
|
80
|
-
|
|
81
|
-
```ts
|
|
82
|
-
class OpenAIAdapter implements LLMProvider {
|
|
83
|
-
constructor(private client: OpenAI) {}
|
|
84
|
-
|
|
85
|
-
async generate(input: GenerateInput): Promise<GenerateResult> {
|
|
86
|
-
try {
|
|
87
|
-
const r = await this.client.chat.completions.create({
|
|
88
|
-
model: 'gpt-4',
|
|
89
|
-
messages: [
|
|
90
|
-
...(input.systemPrompt ? [{ role: 'system' as const, content: input.systemPrompt }] : []),
|
|
91
|
-
{ role: 'user' as const, content: input.prompt },
|
|
92
|
-
],
|
|
93
|
-
max_tokens: input.maxTokens,
|
|
94
|
-
temperature: input.temperature ?? 0,
|
|
95
|
-
seed: input.seed,
|
|
96
|
-
tools: input.tools?.map(t => ({ type: 'function' as const, function: t })),
|
|
97
|
-
})
|
|
98
|
-
return this.toDomain(r)
|
|
99
|
-
} catch (e: any) {
|
|
100
|
-
throw this.translateError(e)
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
private toDomain(r: any): GenerateResult { /* ... */ }
|
|
105
|
-
private translateError(e: any): LLMException {
|
|
106
|
-
if (e.status === 429) return new LLMException('rate_limit', e.message, true)
|
|
107
|
-
if (e.status === 401) return new LLMException('auth', e.message, false)
|
|
108
|
-
if (e.code === 'context_length_exceeded') return new LLMException('context_too_long', e.message, false)
|
|
109
|
-
if (e.code === 'content_filter') return new LLMException('content_filter', e.message, false)
|
|
110
|
-
return new LLMException('unknown', e.message, false)
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
class AnthropicAdapter implements LLMProvider {
|
|
115
|
-
constructor(private client: Anthropic) {}
|
|
116
|
-
|
|
117
|
-
async generate(input: GenerateInput): Promise<GenerateResult> {
|
|
118
|
-
try {
|
|
119
|
-
const r = await this.client.messages.create({
|
|
120
|
-
model: 'claude-opus-4-7',
|
|
121
|
-
max_tokens: input.maxTokens,
|
|
122
|
-
temperature: input.temperature ?? 0,
|
|
123
|
-
system: input.systemPrompt,
|
|
124
|
-
messages: [{ role: 'user' as const, content: input.prompt }],
|
|
125
|
-
tools: input.tools,
|
|
126
|
-
})
|
|
127
|
-
return this.toDomain(r)
|
|
128
|
-
} catch (e: any) {
|
|
129
|
-
throw this.translateError(e)
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
private toDomain(r: any): GenerateResult { /* ... */ }
|
|
134
|
-
private translateError(e: any): LLMException {
|
|
135
|
-
if (e.status === 429) return new LLMException('rate_limit', e.message, true)
|
|
136
|
-
if (e.status === 401) return new LLMException('auth', e.message, false)
|
|
137
|
-
return new LLMException('unknown', e.message, false)
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
### Pattern 3: FakeLLMProvider canônico para testes
|
|
143
|
-
|
|
144
|
-
```ts
|
|
145
|
-
class FakeLLMProvider implements LLMProvider {
|
|
146
|
-
private responses: GenerateResult[] = []
|
|
147
|
-
private index = 0
|
|
148
|
-
private errorMode: LLMError | null = null
|
|
149
|
-
private callLog: GenerateInput[] = []
|
|
150
|
-
|
|
151
|
-
// Configuração
|
|
152
|
-
setResponses(r: Array<Partial<GenerateResult>>): void {
|
|
153
|
-
this.responses = r.map(p => ({
|
|
154
|
-
text: p.text ?? '',
|
|
155
|
-
finishReason: p.finishReason ?? 'stop',
|
|
156
|
-
toolUses: p.toolUses ?? [],
|
|
157
|
-
inputTokens: p.inputTokens ?? 100,
|
|
158
|
-
outputTokens: p.outputTokens ?? 50,
|
|
159
|
-
modelVersion: p.modelVersion ?? 'fake-model-1',
|
|
160
|
-
}))
|
|
161
|
-
this.index = 0
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
failNextWith(code: LLMError): void {
|
|
165
|
-
this.errorMode = code
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Inspeção (assertions)
|
|
169
|
-
callsLog(): GenerateInput[] { return [...this.callLog] }
|
|
170
|
-
callCount(): number { return this.callLog.length }
|
|
171
|
-
|
|
172
|
-
// LLMProvider interface
|
|
173
|
-
async generate(input: GenerateInput): Promise<GenerateResult> {
|
|
174
|
-
this.callLog.push(input)
|
|
175
|
-
|
|
176
|
-
if (this.errorMode) {
|
|
177
|
-
const code = this.errorMode
|
|
178
|
-
this.errorMode = null
|
|
179
|
-
throw new LLMException(code, `fake error: ${code}`, code === 'rate_limit' || code === 'timeout')
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
if (this.index < this.responses.length) {
|
|
183
|
-
return this.responses[this.index++]
|
|
184
|
-
}
|
|
185
|
-
return {
|
|
186
|
-
text: 'fake default response',
|
|
187
|
-
finishReason: 'stop',
|
|
188
|
-
toolUses: [],
|
|
189
|
-
inputTokens: input.prompt.length / 4, // rough estimate
|
|
190
|
-
outputTokens: 50,
|
|
191
|
-
modelVersion: 'fake-model-1',
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// Uso em test
|
|
197
|
-
test('processOrder summary — handles LLM rate limit gracefully', async () => {
|
|
198
|
-
const llm = new FakeLLMProvider()
|
|
199
|
-
llm.failNextWith('rate_limit') // primeiro call falha
|
|
200
|
-
llm.setResponses([{ text: 'Successful summary' }]) // depois funciona
|
|
201
|
-
|
|
202
|
-
const handler = new OrderSummarizer(llm)
|
|
203
|
-
const result = await handler.summarizeOrder({ id: 'O-1', items: [...] })
|
|
204
|
-
|
|
205
|
-
expect(result.summary).toBe('Successful summary')
|
|
206
|
-
expect(llm.callCount()).toBe(2) // 1 falhou + 1 retry sucesso
|
|
207
|
-
})
|
|
208
|
-
```
|
|
209
|
-
|
|
210
|
-
### Pattern 4: Function-based fake (mais flexível)
|
|
211
|
-
|
|
212
|
-
```ts
|
|
213
|
-
// Para tests onde fake response depende do input
|
|
214
|
-
class FunctionalFakeLLM implements LLMProvider {
|
|
215
|
-
constructor(
|
|
216
|
-
private generateFn: (input: GenerateInput) => Promise<GenerateResult> | GenerateResult,
|
|
217
|
-
) {}
|
|
218
|
-
async generate(input: GenerateInput): Promise<GenerateResult> {
|
|
219
|
-
return this.generateFn(input)
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// Uso
|
|
224
|
-
test('summarizer — handles long input via chunking', async () => {
|
|
225
|
-
const llm = new FunctionalFakeLLM(async (input) => {
|
|
226
|
-
// Simular comportamento real: prompt > 8000 chars retorna context_too_long
|
|
227
|
-
if (input.prompt.length > 8000) {
|
|
228
|
-
throw new LLMException('context_too_long', 'too long', false)
|
|
229
|
-
}
|
|
230
|
-
return {
|
|
231
|
-
text: `Summary of ${input.prompt.length} chars`,
|
|
232
|
-
finishReason: 'stop',
|
|
233
|
-
toolUses: [],
|
|
234
|
-
inputTokens: input.prompt.length / 4,
|
|
235
|
-
outputTokens: 30,
|
|
236
|
-
modelVersion: 'fake',
|
|
237
|
-
}
|
|
238
|
-
})
|
|
239
|
-
|
|
240
|
-
const handler = new OrderSummarizer(llm)
|
|
241
|
-
const longOrder = generateLongOrder(15000)
|
|
242
|
-
const result = await handler.summarizeOrder(longOrder)
|
|
243
|
-
expect(result.chunks).toBe(2) // verificou que chunking foi acionado
|
|
244
|
-
})
|
|
245
|
-
```
|
|
246
|
-
|
|
247
|
-
### Pattern 5: Adapter para Edge Function (Supabase + Deno)
|
|
248
|
-
|
|
249
|
-
```ts
|
|
250
|
-
// supabase/functions/_shared/llm.ts
|
|
251
|
-
import { Anthropic } from 'npm:@anthropic-ai/sdk@0.30.0'
|
|
252
|
-
|
|
253
|
-
export interface LLMProvider {
|
|
254
|
-
generate(input: GenerateInput): Promise<GenerateResult>
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
export class AnthropicAdapter implements LLMProvider {
|
|
258
|
-
constructor(private client: Anthropic) {}
|
|
259
|
-
async generate(input: GenerateInput): Promise<GenerateResult> { /* ... */ }
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
export function createLLMProvider(): LLMProvider {
|
|
263
|
-
const apiKey = Deno.env.get('ANTHROPIC_API_KEY')
|
|
264
|
-
if (!apiKey) throw new Error('ANTHROPIC_API_KEY missing')
|
|
265
|
-
return new AnthropicAdapter(new Anthropic({ apiKey }))
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// supabase/functions/summarize-order/index.ts
|
|
269
|
-
import { createLLMProvider } from '../_shared/llm.ts'
|
|
270
|
-
|
|
271
|
-
const llm = createLLMProvider()
|
|
272
|
-
|
|
273
|
-
Deno.serve(async (req) => {
|
|
274
|
-
return await handleRequest(req, llm)
|
|
275
|
-
})
|
|
276
|
-
|
|
277
|
-
export async function handleRequest(req: Request, llm: LLMProvider): Promise<Response> {
|
|
278
|
-
const order = await req.json()
|
|
279
|
-
const summary = await llm.generate({
|
|
280
|
-
prompt: `Resuma este pedido: ${JSON.stringify(order)}`,
|
|
281
|
-
maxTokens: 200,
|
|
282
|
-
})
|
|
283
|
-
return new Response(JSON.stringify({ summary: summary.text }))
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// tests/handle-request.test.ts
|
|
287
|
-
import { handleRequest } from '../supabase/functions/summarize-order/index.ts'
|
|
288
|
-
import { FakeLLMProvider } from './fakes.ts'
|
|
289
|
-
|
|
290
|
-
test('summarize-order — typical request', async () => {
|
|
291
|
-
const llm = new FakeLLMProvider()
|
|
292
|
-
llm.setResponses([{ text: 'Pedido de R$ 50, 2 items.' }])
|
|
293
|
-
|
|
294
|
-
const req = new Request('http://x', {
|
|
295
|
-
method: 'POST',
|
|
296
|
-
body: JSON.stringify({ id: 'O-1', total: 5000, items: ['SKU-1', 'SKU-2'] }),
|
|
297
|
-
})
|
|
298
|
-
const res = await handleRequest(req, llm)
|
|
299
|
-
const body = await res.json()
|
|
300
|
-
expect(body.summary).toBe('Pedido de R$ 50, 2 items.')
|
|
301
|
-
})
|
|
302
|
-
```
|
|
303
|
-
|
|
304
|
-
### Pattern 6: Cross-cutting concerns no adapter
|
|
305
|
-
|
|
306
|
-
Adapter é o lugar canônico para retry, timeout, observability:
|
|
307
|
-
|
|
308
|
-
```ts
|
|
309
|
-
class ResilientLLMAdapter implements LLMProvider {
|
|
310
|
-
constructor(
|
|
311
|
-
private inner: LLMProvider,
|
|
312
|
-
private logger: Logger,
|
|
313
|
-
private metrics: Metrics,
|
|
314
|
-
) {}
|
|
315
|
-
|
|
316
|
-
async generate(input: GenerateInput): Promise<GenerateResult> {
|
|
317
|
-
const startMs = performance.now()
|
|
318
|
-
try {
|
|
319
|
-
const result = await retryWithJitter(
|
|
320
|
-
() => this.inner.generate(input),
|
|
321
|
-
{ maxRetries: 3, baseMs: 500, retryOn: (e) => e instanceof LLMException && e.retryable }
|
|
322
|
-
)
|
|
323
|
-
|
|
324
|
-
const latency = performance.now() - startMs
|
|
325
|
-
this.metrics.histogram('llm.generate.latency_ms', latency, { result: 'success' })
|
|
326
|
-
this.metrics.counter('llm.generate.tokens', result.inputTokens + result.outputTokens)
|
|
327
|
-
this.logger.info('llm.generate.ok', { latency_ms: latency, model: result.modelVersion })
|
|
328
|
-
return result
|
|
329
|
-
} catch (e) {
|
|
330
|
-
const latency = performance.now() - startMs
|
|
331
|
-
this.metrics.histogram('llm.generate.latency_ms', latency, { result: 'error' })
|
|
332
|
-
this.metrics.counter('llm.errors', 1, { error_type: (e as LLMException).code })
|
|
333
|
-
this.logger.warn('llm.generate.failed', { error: (e as Error).message })
|
|
334
|
-
throw e
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// Composição em produção
|
|
340
|
-
const llm = new ResilientLLMAdapter(
|
|
341
|
-
new AnthropicAdapter(new Anthropic({ apiKey })),
|
|
342
|
-
logger,
|
|
343
|
-
metrics,
|
|
344
|
-
)
|
|
345
|
-
```
|
|
346
|
-
|
|
347
|
-
## Anti-patterns
|
|
348
|
-
|
|
349
|
-
### ANTI: handler chama OpenAI direto
|
|
350
|
-
|
|
351
|
-
```text
|
|
352
|
-
ANTI: handler.ts: import OpenAI from 'openai'; const client = new OpenAI(...);
|
|
353
|
-
... await client.chat.completions.create(...)
|
|
354
|
-
|
|
355
|
-
PROBLEMA: handler intestável sem mock global. Trocar Anthropic =
|
|
356
|
-
rewrite. Tests rodam contra API real (lento + custo +
|
|
357
|
-
flaky).
|
|
358
|
-
|
|
359
|
-
CERTO: handler depende de LLMProvider. Adapter encapsula. Tests
|
|
360
|
-
usam FakeLLMProvider.
|
|
361
|
-
```
|
|
362
|
-
|
|
363
|
-
### ANTI: tests rodam contra LLM real
|
|
364
|
-
|
|
365
|
-
```text
|
|
366
|
-
ANTI: tests de business logic chamam OpenAI/Anthropic real.
|
|
367
|
-
|
|
368
|
-
PROBLEMA: testes lentos (5s por call), custosos ($X por suite),
|
|
369
|
-
flaky (rate limits, network), não-determinísticos
|
|
370
|
-
(mesma input pode gerar texto diferente entre runs).
|
|
371
|
-
|
|
372
|
-
CERTO: business logic tests usam FakeLLMProvider. LLM REAL apenas
|
|
373
|
-
em tests específicos de characterization de PROMPT (skill
|
|
374
|
-
`ai-prompt-characterization`). 99% dos tests = fakes.
|
|
375
|
-
```
|
|
376
|
-
|
|
377
|
-
### ANTI: fake retorna sempre mesma resposta
|
|
378
|
-
|
|
379
|
-
```text
|
|
380
|
-
ANTI: FakeLLM hardcoded retorna `text: "fake response"` sempre.
|
|
381
|
-
|
|
382
|
-
PROBLEMA: tests não conseguem simular caminhos múltiplos
|
|
383
|
-
(sucesso/falha/edge case). Cobertura baixa.
|
|
384
|
-
|
|
385
|
-
CERTO: FakeLLM com setResponses() OR FunctionalFakeLLM. Cada teste
|
|
386
|
-
configura comportamento esperado para aquele caso.
|
|
387
|
-
```
|
|
388
|
-
|
|
389
|
-
### ANTI: adapter expõe tipos do vendor
|
|
390
|
-
|
|
391
|
-
```text
|
|
392
|
-
ANTI: interface LLMProvider { generate(input): Promise<OpenAI.Chat.Completion> }
|
|
393
|
-
|
|
394
|
-
PROBLEMA: OpenAI.Chat.Completion atravessa camadas internas.
|
|
395
|
-
Mudança no SDK do OpenAI cascateia.
|
|
396
|
-
|
|
397
|
-
CERTO: GenerateResult é tipo PRÓPRIO do domínio. Adapter traduz
|
|
398
|
-
OpenAI.Chat.Completion → GenerateResult. Anti-corruption
|
|
399
|
-
layer.
|
|
400
|
-
```
|
|
401
|
-
|
|
402
|
-
### ANTI: tratar rate_limit como `any error`
|
|
403
|
-
|
|
404
|
-
```text
|
|
405
|
-
ANTI: catch (e) { if (e.status === 429) retry; else throw }
|
|
406
|
-
|
|
407
|
-
PROBLEMA: lógica de erro acoplada à HTTP status do vendor. Outro
|
|
408
|
-
provider tem outros códigos. Lógica duplicada.
|
|
409
|
-
|
|
410
|
-
CERTO: adapter traduz error para LLMException com código domain.
|
|
411
|
-
Handler trata LLMException.code, não status HTTP.
|
|
412
|
-
```
|
|
413
|
-
|
|
414
|
-
## Verificação
|
|
415
|
-
|
|
416
|
-
1. Handler depende de `LLMProvider` interface
|
|
417
|
-
2. Adapter por vendor encapsula SDK
|
|
418
|
-
3. FakeLLMProvider existe e tests usam
|
|
419
|
-
4. Erros traduzidos para `LLMException` com código domain
|
|
420
|
-
5. ResilientLLMAdapter (com retry + observability) usado em produção
|
|
421
|
-
6. Tests de business logic NUNCA chamam LLM real (CI custa $0)
|
|
422
|
-
7. Trocar provider = trocar 1 linha (constructor)
|
|
423
|
-
|
|
424
|
-
---
|
|
425
|
-
|
|
426
|
-
## Ver também
|
|
427
|
-
|
|
428
|
-
- [`_shared-legacy/glossary.md`](../_shared-legacy/glossary.md) — vocabulário (adapter, anti-corruption)
|
|
429
|
-
- [`legacy-api-only-applications`](../legacy-api-only-applications/SKILL.md) — pattern canônico (LLM provider é caso especial)
|
|
430
|
-
- [`legacy-seams-and-test-harness`](../legacy-seams-and-test-harness/SKILL.md) — extract-interface é técnica do cap 25
|
|
431
|
-
- [`ai-prompt-characterization`](../ai-prompt-characterization/SKILL.md) — characterization de PROMPT (LLM real); essa skill é para tudo MAIS LLM (LLM fake)
|
|
432
|
-
- [`supabase-edge-fn-writer`](../../agents/supabase-edge-fn-writer.md) (v1.8) — patch v1.12: detecta uso de LLM e oferece DI pattern
|
|
433
|
-
- [`four-golden-signals`](../four-golden-signals/SKILL.md) (v1.10) — adapter é onde golden signals são instrumentados
|
|
434
|
-
- [`retry-strategies`](../retry-strategies/SKILL.md) (v1.11) — retry com jitter aplicado em ResilientLLMAdapter
|
|
435
|
-
|
|
436
|
-
*Material-fonte (modernização 2026):* Sem precedente em livro Feathers 2004 — LLMs como dependência de produção é literatura recente (2023+).
|
|
1
|
+
---
|
|
2
|
+
name: llm-as-dependency
|
|
3
|
+
description: Use ao escrever código que depende de LLM (OpenAI/Anthropic) — adapter pattern + FakeLLMProvider para testes determinísticos sem custo. Modernização 2026 sem precedente em 2004.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# LLM as Dependency (Modernização)
|
|
7
|
+
|
|
8
|
+
## Quando usar
|
|
9
|
+
|
|
10
|
+
LLM carrega esta skill quando user vai escrever ou refatorar código que chama LLM provider em produção. Trigger phrases:
|
|
11
|
+
|
|
12
|
+
- "como testar essa função que chama OpenAI?"
|
|
13
|
+
- "fake do client Anthropic", "mock LLM"
|
|
14
|
+
- "tornar testável código com LLM"
|
|
15
|
+
- "deterministic test mode para LLM"
|
|
16
|
+
- "DI de OpenAI/Anthropic client"
|
|
17
|
+
- "edge function usa LLM, como caracterizar?"
|
|
18
|
+
- "function calling em testes"
|
|
19
|
+
|
|
20
|
+
## Regras absolutas
|
|
21
|
+
|
|
22
|
+
- **LLM é DEPENDÊNCIA EXTERNA igual a DB ou HTTP API.** Mesmas regras: nunca acoplar handler ao SDK específico; sempre via interface; sempre com fake disponível.
|
|
23
|
+
- **Adapter pattern (do skill `legacy-api-only-applications`) é a aplicação correta.** Interface mínima `LLMProvider`; adapter por vendor; fake por testes.
|
|
24
|
+
- **Testes de BUSINESS LOGIC nunca chamam LLM real.** FakeLLMProvider canned responses. LLM real é exclusivamente em characterization tests de PROMPT (skill `ai-prompt-characterization`).
|
|
25
|
+
- **Determinismo via fake.** FakeLLMProvider retorna outputs fixos em ordem. Não há non-determinism em test de business logic.
|
|
26
|
+
- **Token tracking é cross-cutting concern do adapter.** Handler não precisa saber tokens. Adapter loga + opcionalmente reporta para observability.
|
|
27
|
+
- **Tradução de erros: vendor → domain.** Rate limit do OpenAI ≠ Rate limit do Anthropic ≠ Rate limit do Claude API. Adapter traduz para enum próprio (`LLMError = 'rate_limit' | 'timeout' | 'context_too_long' | 'content_filter' | 'auth' | 'unknown'`).
|
|
28
|
+
- **Fake suporta múltiplos modos:** (a) canned responses, (b) function-based responses (`(input) => output`), (c) error injection (`shouldFail: 'rate_limit'`).
|
|
29
|
+
|
|
30
|
+
## Patterns canônicos
|
|
31
|
+
|
|
32
|
+
### Pattern 1: Interface canônica `LLMProvider`
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
// Interface mínima — handler não vê SDK específico
|
|
36
|
+
interface LLMProvider {
|
|
37
|
+
generate(input: GenerateInput): Promise<GenerateResult>
|
|
38
|
+
embed?(input: EmbedInput): Promise<EmbedResult> // opcional, só para providers que suportam
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface GenerateInput {
|
|
42
|
+
prompt: string // ou messages, dependendo do provider
|
|
43
|
+
systemPrompt?: string
|
|
44
|
+
maxTokens: number
|
|
45
|
+
temperature?: number
|
|
46
|
+
seed?: number // determinismo
|
|
47
|
+
tools?: ToolDefinition[]
|
|
48
|
+
toolChoice?: 'auto' | 'none' | { type: 'tool'; name: string }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface GenerateResult {
|
|
52
|
+
text: string
|
|
53
|
+
finishReason: 'stop' | 'length' | 'tool_use' | 'content_filter'
|
|
54
|
+
toolUses: Array<{ name: string; input: any }>
|
|
55
|
+
inputTokens: number
|
|
56
|
+
outputTokens: number
|
|
57
|
+
modelVersion: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface EmbedInput {
|
|
61
|
+
texts: string[]
|
|
62
|
+
model?: string // canônico: 'text-embedding-3-small' default; provider mapeia
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface EmbedResult {
|
|
66
|
+
embeddings: number[][] // 1 vector por input
|
|
67
|
+
inputTokens: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// LLM error canônico (anti-corruption layer)
|
|
71
|
+
type LLMError = 'rate_limit' | 'timeout' | 'context_too_long' | 'content_filter' | 'auth' | 'invalid_request' | 'server_error' | 'unknown'
|
|
72
|
+
class LLMException extends Error {
|
|
73
|
+
constructor(public code: LLMError, message: string, public retryable: boolean) {
|
|
74
|
+
super(message)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Pattern 2: Adapters por vendor
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
class OpenAIAdapter implements LLMProvider {
|
|
83
|
+
constructor(private client: OpenAI) {}
|
|
84
|
+
|
|
85
|
+
async generate(input: GenerateInput): Promise<GenerateResult> {
|
|
86
|
+
try {
|
|
87
|
+
const r = await this.client.chat.completions.create({
|
|
88
|
+
model: 'gpt-4',
|
|
89
|
+
messages: [
|
|
90
|
+
...(input.systemPrompt ? [{ role: 'system' as const, content: input.systemPrompt }] : []),
|
|
91
|
+
{ role: 'user' as const, content: input.prompt },
|
|
92
|
+
],
|
|
93
|
+
max_tokens: input.maxTokens,
|
|
94
|
+
temperature: input.temperature ?? 0,
|
|
95
|
+
seed: input.seed,
|
|
96
|
+
tools: input.tools?.map(t => ({ type: 'function' as const, function: t })),
|
|
97
|
+
})
|
|
98
|
+
return this.toDomain(r)
|
|
99
|
+
} catch (e: any) {
|
|
100
|
+
throw this.translateError(e)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private toDomain(r: any): GenerateResult { /* ... */ }
|
|
105
|
+
private translateError(e: any): LLMException {
|
|
106
|
+
if (e.status === 429) return new LLMException('rate_limit', e.message, true)
|
|
107
|
+
if (e.status === 401) return new LLMException('auth', e.message, false)
|
|
108
|
+
if (e.code === 'context_length_exceeded') return new LLMException('context_too_long', e.message, false)
|
|
109
|
+
if (e.code === 'content_filter') return new LLMException('content_filter', e.message, false)
|
|
110
|
+
return new LLMException('unknown', e.message, false)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
class AnthropicAdapter implements LLMProvider {
|
|
115
|
+
constructor(private client: Anthropic) {}
|
|
116
|
+
|
|
117
|
+
async generate(input: GenerateInput): Promise<GenerateResult> {
|
|
118
|
+
try {
|
|
119
|
+
const r = await this.client.messages.create({
|
|
120
|
+
model: 'claude-opus-4-7',
|
|
121
|
+
max_tokens: input.maxTokens,
|
|
122
|
+
temperature: input.temperature ?? 0,
|
|
123
|
+
system: input.systemPrompt,
|
|
124
|
+
messages: [{ role: 'user' as const, content: input.prompt }],
|
|
125
|
+
tools: input.tools,
|
|
126
|
+
})
|
|
127
|
+
return this.toDomain(r)
|
|
128
|
+
} catch (e: any) {
|
|
129
|
+
throw this.translateError(e)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private toDomain(r: any): GenerateResult { /* ... */ }
|
|
134
|
+
private translateError(e: any): LLMException {
|
|
135
|
+
if (e.status === 429) return new LLMException('rate_limit', e.message, true)
|
|
136
|
+
if (e.status === 401) return new LLMException('auth', e.message, false)
|
|
137
|
+
return new LLMException('unknown', e.message, false)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Pattern 3: FakeLLMProvider canônico para testes
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
class FakeLLMProvider implements LLMProvider {
|
|
146
|
+
private responses: GenerateResult[] = []
|
|
147
|
+
private index = 0
|
|
148
|
+
private errorMode: LLMError | null = null
|
|
149
|
+
private callLog: GenerateInput[] = []
|
|
150
|
+
|
|
151
|
+
// Configuração
|
|
152
|
+
setResponses(r: Array<Partial<GenerateResult>>): void {
|
|
153
|
+
this.responses = r.map(p => ({
|
|
154
|
+
text: p.text ?? '',
|
|
155
|
+
finishReason: p.finishReason ?? 'stop',
|
|
156
|
+
toolUses: p.toolUses ?? [],
|
|
157
|
+
inputTokens: p.inputTokens ?? 100,
|
|
158
|
+
outputTokens: p.outputTokens ?? 50,
|
|
159
|
+
modelVersion: p.modelVersion ?? 'fake-model-1',
|
|
160
|
+
}))
|
|
161
|
+
this.index = 0
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
failNextWith(code: LLMError): void {
|
|
165
|
+
this.errorMode = code
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Inspeção (assertions)
|
|
169
|
+
callsLog(): GenerateInput[] { return [...this.callLog] }
|
|
170
|
+
callCount(): number { return this.callLog.length }
|
|
171
|
+
|
|
172
|
+
// LLMProvider interface
|
|
173
|
+
async generate(input: GenerateInput): Promise<GenerateResult> {
|
|
174
|
+
this.callLog.push(input)
|
|
175
|
+
|
|
176
|
+
if (this.errorMode) {
|
|
177
|
+
const code = this.errorMode
|
|
178
|
+
this.errorMode = null
|
|
179
|
+
throw new LLMException(code, `fake error: ${code}`, code === 'rate_limit' || code === 'timeout')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (this.index < this.responses.length) {
|
|
183
|
+
return this.responses[this.index++]
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
text: 'fake default response',
|
|
187
|
+
finishReason: 'stop',
|
|
188
|
+
toolUses: [],
|
|
189
|
+
inputTokens: input.prompt.length / 4, // rough estimate
|
|
190
|
+
outputTokens: 50,
|
|
191
|
+
modelVersion: 'fake-model-1',
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Uso em test
|
|
197
|
+
test('processOrder summary — handles LLM rate limit gracefully', async () => {
|
|
198
|
+
const llm = new FakeLLMProvider()
|
|
199
|
+
llm.failNextWith('rate_limit') // primeiro call falha
|
|
200
|
+
llm.setResponses([{ text: 'Successful summary' }]) // depois funciona
|
|
201
|
+
|
|
202
|
+
const handler = new OrderSummarizer(llm)
|
|
203
|
+
const result = await handler.summarizeOrder({ id: 'O-1', items: [...] })
|
|
204
|
+
|
|
205
|
+
expect(result.summary).toBe('Successful summary')
|
|
206
|
+
expect(llm.callCount()).toBe(2) // 1 falhou + 1 retry sucesso
|
|
207
|
+
})
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Pattern 4: Function-based fake (mais flexível)
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
// Para tests onde fake response depende do input
|
|
214
|
+
class FunctionalFakeLLM implements LLMProvider {
|
|
215
|
+
constructor(
|
|
216
|
+
private generateFn: (input: GenerateInput) => Promise<GenerateResult> | GenerateResult,
|
|
217
|
+
) {}
|
|
218
|
+
async generate(input: GenerateInput): Promise<GenerateResult> {
|
|
219
|
+
return this.generateFn(input)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Uso
|
|
224
|
+
test('summarizer — handles long input via chunking', async () => {
|
|
225
|
+
const llm = new FunctionalFakeLLM(async (input) => {
|
|
226
|
+
// Simular comportamento real: prompt > 8000 chars retorna context_too_long
|
|
227
|
+
if (input.prompt.length > 8000) {
|
|
228
|
+
throw new LLMException('context_too_long', 'too long', false)
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
text: `Summary of ${input.prompt.length} chars`,
|
|
232
|
+
finishReason: 'stop',
|
|
233
|
+
toolUses: [],
|
|
234
|
+
inputTokens: input.prompt.length / 4,
|
|
235
|
+
outputTokens: 30,
|
|
236
|
+
modelVersion: 'fake',
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
const handler = new OrderSummarizer(llm)
|
|
241
|
+
const longOrder = generateLongOrder(15000)
|
|
242
|
+
const result = await handler.summarizeOrder(longOrder)
|
|
243
|
+
expect(result.chunks).toBe(2) // verificou que chunking foi acionado
|
|
244
|
+
})
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### Pattern 5: Adapter para Edge Function (Supabase + Deno)
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
// supabase/functions/_shared/llm.ts
|
|
251
|
+
import { Anthropic } from 'npm:@anthropic-ai/sdk@0.30.0'
|
|
252
|
+
|
|
253
|
+
export interface LLMProvider {
|
|
254
|
+
generate(input: GenerateInput): Promise<GenerateResult>
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export class AnthropicAdapter implements LLMProvider {
|
|
258
|
+
constructor(private client: Anthropic) {}
|
|
259
|
+
async generate(input: GenerateInput): Promise<GenerateResult> { /* ... */ }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function createLLMProvider(): LLMProvider {
|
|
263
|
+
const apiKey = Deno.env.get('ANTHROPIC_API_KEY')
|
|
264
|
+
if (!apiKey) throw new Error('ANTHROPIC_API_KEY missing')
|
|
265
|
+
return new AnthropicAdapter(new Anthropic({ apiKey }))
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// supabase/functions/summarize-order/index.ts
|
|
269
|
+
import { createLLMProvider } from '../_shared/llm.ts'
|
|
270
|
+
|
|
271
|
+
const llm = createLLMProvider()
|
|
272
|
+
|
|
273
|
+
Deno.serve(async (req) => {
|
|
274
|
+
return await handleRequest(req, llm)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
export async function handleRequest(req: Request, llm: LLMProvider): Promise<Response> {
|
|
278
|
+
const order = await req.json()
|
|
279
|
+
const summary = await llm.generate({
|
|
280
|
+
prompt: `Resuma este pedido: ${JSON.stringify(order)}`,
|
|
281
|
+
maxTokens: 200,
|
|
282
|
+
})
|
|
283
|
+
return new Response(JSON.stringify({ summary: summary.text }))
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// tests/handle-request.test.ts
|
|
287
|
+
import { handleRequest } from '../supabase/functions/summarize-order/index.ts'
|
|
288
|
+
import { FakeLLMProvider } from './fakes.ts'
|
|
289
|
+
|
|
290
|
+
test('summarize-order — typical request', async () => {
|
|
291
|
+
const llm = new FakeLLMProvider()
|
|
292
|
+
llm.setResponses([{ text: 'Pedido de R$ 50, 2 items.' }])
|
|
293
|
+
|
|
294
|
+
const req = new Request('http://x', {
|
|
295
|
+
method: 'POST',
|
|
296
|
+
body: JSON.stringify({ id: 'O-1', total: 5000, items: ['SKU-1', 'SKU-2'] }),
|
|
297
|
+
})
|
|
298
|
+
const res = await handleRequest(req, llm)
|
|
299
|
+
const body = await res.json()
|
|
300
|
+
expect(body.summary).toBe('Pedido de R$ 50, 2 items.')
|
|
301
|
+
})
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### Pattern 6: Cross-cutting concerns no adapter
|
|
305
|
+
|
|
306
|
+
Adapter é o lugar canônico para retry, timeout, observability:
|
|
307
|
+
|
|
308
|
+
```ts
|
|
309
|
+
class ResilientLLMAdapter implements LLMProvider {
|
|
310
|
+
constructor(
|
|
311
|
+
private inner: LLMProvider,
|
|
312
|
+
private logger: Logger,
|
|
313
|
+
private metrics: Metrics,
|
|
314
|
+
) {}
|
|
315
|
+
|
|
316
|
+
async generate(input: GenerateInput): Promise<GenerateResult> {
|
|
317
|
+
const startMs = performance.now()
|
|
318
|
+
try {
|
|
319
|
+
const result = await retryWithJitter(
|
|
320
|
+
() => this.inner.generate(input),
|
|
321
|
+
{ maxRetries: 3, baseMs: 500, retryOn: (e) => e instanceof LLMException && e.retryable }
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
const latency = performance.now() - startMs
|
|
325
|
+
this.metrics.histogram('llm.generate.latency_ms', latency, { result: 'success' })
|
|
326
|
+
this.metrics.counter('llm.generate.tokens', result.inputTokens + result.outputTokens)
|
|
327
|
+
this.logger.info('llm.generate.ok', { latency_ms: latency, model: result.modelVersion })
|
|
328
|
+
return result
|
|
329
|
+
} catch (e) {
|
|
330
|
+
const latency = performance.now() - startMs
|
|
331
|
+
this.metrics.histogram('llm.generate.latency_ms', latency, { result: 'error' })
|
|
332
|
+
this.metrics.counter('llm.errors', 1, { error_type: (e as LLMException).code })
|
|
333
|
+
this.logger.warn('llm.generate.failed', { error: (e as Error).message })
|
|
334
|
+
throw e
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Composição em produção
|
|
340
|
+
const llm = new ResilientLLMAdapter(
|
|
341
|
+
new AnthropicAdapter(new Anthropic({ apiKey })),
|
|
342
|
+
logger,
|
|
343
|
+
metrics,
|
|
344
|
+
)
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
## Anti-patterns
|
|
348
|
+
|
|
349
|
+
### ANTI: handler chama OpenAI direto
|
|
350
|
+
|
|
351
|
+
```text
|
|
352
|
+
ANTI: handler.ts: import OpenAI from 'openai'; const client = new OpenAI(...);
|
|
353
|
+
... await client.chat.completions.create(...)
|
|
354
|
+
|
|
355
|
+
PROBLEMA: handler intestável sem mock global. Trocar Anthropic =
|
|
356
|
+
rewrite. Tests rodam contra API real (lento + custo +
|
|
357
|
+
flaky).
|
|
358
|
+
|
|
359
|
+
CERTO: handler depende de LLMProvider. Adapter encapsula. Tests
|
|
360
|
+
usam FakeLLMProvider.
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
### ANTI: tests rodam contra LLM real
|
|
364
|
+
|
|
365
|
+
```text
|
|
366
|
+
ANTI: tests de business logic chamam OpenAI/Anthropic real.
|
|
367
|
+
|
|
368
|
+
PROBLEMA: testes lentos (5s por call), custosos ($X por suite),
|
|
369
|
+
flaky (rate limits, network), não-determinísticos
|
|
370
|
+
(mesma input pode gerar texto diferente entre runs).
|
|
371
|
+
|
|
372
|
+
CERTO: business logic tests usam FakeLLMProvider. LLM REAL apenas
|
|
373
|
+
em tests específicos de characterization de PROMPT (skill
|
|
374
|
+
`ai-prompt-characterization`). 99% dos tests = fakes.
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### ANTI: fake retorna sempre mesma resposta
|
|
378
|
+
|
|
379
|
+
```text
|
|
380
|
+
ANTI: FakeLLM hardcoded retorna `text: "fake response"` sempre.
|
|
381
|
+
|
|
382
|
+
PROBLEMA: tests não conseguem simular caminhos múltiplos
|
|
383
|
+
(sucesso/falha/edge case). Cobertura baixa.
|
|
384
|
+
|
|
385
|
+
CERTO: FakeLLM com setResponses() OR FunctionalFakeLLM. Cada teste
|
|
386
|
+
configura comportamento esperado para aquele caso.
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
### ANTI: adapter expõe tipos do vendor
|
|
390
|
+
|
|
391
|
+
```text
|
|
392
|
+
ANTI: interface LLMProvider { generate(input): Promise<OpenAI.Chat.Completion> }
|
|
393
|
+
|
|
394
|
+
PROBLEMA: OpenAI.Chat.Completion atravessa camadas internas.
|
|
395
|
+
Mudança no SDK do OpenAI cascateia.
|
|
396
|
+
|
|
397
|
+
CERTO: GenerateResult é tipo PRÓPRIO do domínio. Adapter traduz
|
|
398
|
+
OpenAI.Chat.Completion → GenerateResult. Anti-corruption
|
|
399
|
+
layer.
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
### ANTI: tratar rate_limit como `any error`
|
|
403
|
+
|
|
404
|
+
```text
|
|
405
|
+
ANTI: catch (e) { if (e.status === 429) retry; else throw }
|
|
406
|
+
|
|
407
|
+
PROBLEMA: lógica de erro acoplada à HTTP status do vendor. Outro
|
|
408
|
+
provider tem outros códigos. Lógica duplicada.
|
|
409
|
+
|
|
410
|
+
CERTO: adapter traduz error para LLMException com código domain.
|
|
411
|
+
Handler trata LLMException.code, não status HTTP.
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
## Verificação
|
|
415
|
+
|
|
416
|
+
1. Handler depende de `LLMProvider` interface
|
|
417
|
+
2. Adapter por vendor encapsula SDK
|
|
418
|
+
3. FakeLLMProvider existe e tests usam
|
|
419
|
+
4. Erros traduzidos para `LLMException` com código domain
|
|
420
|
+
5. ResilientLLMAdapter (com retry + observability) usado em produção
|
|
421
|
+
6. Tests de business logic NUNCA chamam LLM real (CI custa $0)
|
|
422
|
+
7. Trocar provider = trocar 1 linha (constructor)
|
|
423
|
+
|
|
424
|
+
---
|
|
425
|
+
|
|
426
|
+
## Ver também
|
|
427
|
+
|
|
428
|
+
- [`_shared-legacy/glossary.md`](../_shared-legacy/glossary.md) — vocabulário (adapter, anti-corruption)
|
|
429
|
+
- [`legacy-api-only-applications`](../legacy-api-only-applications/SKILL.md) — pattern canônico (LLM provider é caso especial)
|
|
430
|
+
- [`legacy-seams-and-test-harness`](../legacy-seams-and-test-harness/SKILL.md) — extract-interface é técnica do cap 25
|
|
431
|
+
- [`ai-prompt-characterization`](../ai-prompt-characterization/SKILL.md) — characterization de PROMPT (LLM real); essa skill é para tudo MAIS LLM (LLM fake)
|
|
432
|
+
- [`supabase-edge-fn-writer`](../../agents/supabase-edge-fn-writer.md) (v1.8) — patch v1.12: detecta uso de LLM e oferece DI pattern
|
|
433
|
+
- [`four-golden-signals`](../four-golden-signals/SKILL.md) (v1.10) — adapter é onde golden signals são instrumentados
|
|
434
|
+
- [`retry-strategies`](../retry-strategies/SKILL.md) (v1.11) — retry com jitter aplicado em ResilientLLMAdapter
|
|
435
|
+
|
|
436
|
+
*Material-fonte (modernização 2026):* Sem precedente em livro Feathers 2004 — LLMs como dependência de produção é literatura recente (2023+).
|