@flowcodex/core 0.3.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/LICENSE +21 -0
- package/README.md +9 -0
- package/dist/index-LbxYtxxS.d.ts +560 -0
- package/dist/index.d.ts +995 -0
- package/dist/index.js +3840 -0
- package/dist/index.js.map +1 -0
- package/dist/kernel/index.d.ts +1 -0
- package/dist/kernel/index.js +551 -0
- package/dist/kernel/index.js.map +1 -0
- package/package.json +39 -0
- package/src/agent/agent-loop.ts +254 -0
- package/src/agent/context.ts +99 -0
- package/src/agent/conversation-state.ts +44 -0
- package/src/agent/provider-runner.ts +241 -0
- package/src/agent/system-prompt-builder.ts +193 -0
- package/src/execution/compactor.ts +256 -0
- package/src/execution/index.ts +7 -0
- package/src/execution/output-serializer.ts +90 -0
- package/src/execution/schema-validator.ts +124 -0
- package/src/execution/tool-executor.ts +276 -0
- package/src/execution/tool-registry.ts +104 -0
- package/src/index.ts +215 -0
- package/src/infrastructure/catalog-parser.ts +218 -0
- package/src/infrastructure/index.ts +16 -0
- package/src/infrastructure/path-resolver.ts +123 -0
- package/src/infrastructure/provider-factory.ts +116 -0
- package/src/infrastructure/provider-presets.ts +19 -0
- package/src/infrastructure/retry-policy.ts +50 -0
- package/src/infrastructure/secret-scrubber.ts +67 -0
- package/src/infrastructure/token-counter.ts +156 -0
- package/src/infrastructure/tracer.ts +23 -0
- package/src/kernel/container.ts +166 -0
- package/src/kernel/events.ts +323 -0
- package/src/kernel/index.ts +18 -0
- package/src/kernel/pipeline.ts +152 -0
- package/src/kernel/run-controller.ts +85 -0
- package/src/kernel/tokens.ts +21 -0
- package/src/security/index.ts +13 -0
- package/src/security/permission-policy.ts +273 -0
- package/src/session/audit-log.ts +201 -0
- package/src/session/auth-service.ts +178 -0
- package/src/session/index.ts +26 -0
- package/src/session/secret-vault.ts +183 -0
- package/src/session/session-store.ts +339 -0
- package/src/session/types.ts +100 -0
- package/src/types/blocks.ts +56 -0
- package/src/types/context.ts +54 -0
- package/src/types/errors.ts +359 -0
- package/src/types/index.ts +34 -0
- package/src/types/provider.ts +58 -0
- package/src/types/tool.ts +39 -0
- package/src/utils/error.ts +3 -0
- package/src/utils/fs.ts +185 -0
- package/src/utils/image-resize.ts +76 -0
- package/src/utils/ssrf-guard.ts +133 -0
- package/src/utils/ulid.ts +72 -0
- package/src/utils/version-check.ts +59 -0
- package/tests/agent-loop.test.ts +490 -0
- package/tests/audit-log.test.ts +199 -0
- package/tests/auth-service.test.ts +170 -0
- package/tests/blocks.test.ts +79 -0
- package/tests/catalog-parser.test.ts +174 -0
- package/tests/compactor.test.ts +180 -0
- package/tests/container.test.ts +224 -0
- package/tests/conversation-state.test.ts +75 -0
- package/tests/errors.test.ts +429 -0
- package/tests/events-v021.test.ts +60 -0
- package/tests/events-v022.test.ts +75 -0
- package/tests/events.test.ts +340 -0
- package/tests/fixtures/large-image.png +0 -0
- package/tests/fixtures/small-image.png +0 -0
- package/tests/fs-utils.test.ts +164 -0
- package/tests/image-resize.test.ts +51 -0
- package/tests/output-serializer.test.ts +79 -0
- package/tests/path-resolver.test.ts +91 -0
- package/tests/permission-policy.test.ts +174 -0
- package/tests/pipeline.test.ts +193 -0
- package/tests/provider-factory.test.ts +245 -0
- package/tests/provider-runner.test.ts +535 -0
- package/tests/retry-policy.test.ts +104 -0
- package/tests/run-controller.test.ts +115 -0
- package/tests/sanity.test.ts +26 -0
- package/tests/schema-validator.test.ts +109 -0
- package/tests/secret-scrubber.test.ts +133 -0
- package/tests/secret-vault.test.ts +130 -0
- package/tests/session-store.test.ts +429 -0
- package/tests/ssrf-guard.test.ts +112 -0
- package/tests/system-prompt-builder.test.ts +116 -0
- package/tests/token-counter.test.ts +163 -0
- package/tests/tokens.test.ts +42 -0
- package/tests/tool-executor.test.ts +452 -0
- package/tests/tool-registry.test.ts +143 -0
- package/tests/tracer.test.ts +32 -0
- package/tests/ulid.test.ts +53 -0
- package/tests/version-check.test.ts +57 -0
- package/tsconfig.json +11 -0
- package/tsup.config.ts +16 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { promises as fsp } from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
|
|
5
|
+
export type WireFamily = 'anthropic' | 'openai' | 'openai-compatible' | 'google' | 'unsupported';
|
|
6
|
+
|
|
7
|
+
export function classifyFamily(npm: string | undefined): WireFamily {
|
|
8
|
+
if (!npm) return 'unsupported';
|
|
9
|
+
if (npm === '@anthropic-ai/sdk') return 'anthropic';
|
|
10
|
+
if (npm === 'openai' || npm === '@openai/api') return 'openai';
|
|
11
|
+
if (npm === '@google/genai' || npm === '@google/generative-ai') return 'google';
|
|
12
|
+
if (npm.includes('openai') || npm.includes('compatible')) return 'openai-compatible';
|
|
13
|
+
return 'unsupported';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ModelInfo {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
provider: string;
|
|
20
|
+
npm?: string | undefined;
|
|
21
|
+
reasoning: boolean;
|
|
22
|
+
reasoning_options?: unknown[] | undefined;
|
|
23
|
+
limit: { context: number; output: number };
|
|
24
|
+
cost?: {
|
|
25
|
+
input: number;
|
|
26
|
+
output: number;
|
|
27
|
+
cache_read?: number | undefined;
|
|
28
|
+
cache_write?: number | undefined;
|
|
29
|
+
} | undefined;
|
|
30
|
+
tool_call: boolean;
|
|
31
|
+
modalities: {
|
|
32
|
+
input: string[];
|
|
33
|
+
output: string[];
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CatalogEntry {
|
|
38
|
+
models: ModelInfo[];
|
|
39
|
+
fetchedAt: number;
|
|
40
|
+
source: 'snapshot' | 'cache' | 'network';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CatalogParser {
|
|
44
|
+
load(): Promise<CatalogEntry>;
|
|
45
|
+
getModels(provider?: string): ModelInfo[];
|
|
46
|
+
getModel(id: string): ModelInfo | undefined;
|
|
47
|
+
getCheapestModel(providerId: string, excludeModelId: string): ModelInfo | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const CACHE_DIR = path.join(os.homedir(), '.flowcodex', 'cache');
|
|
51
|
+
const CACHE_FILE = path.join(CACHE_DIR, 'models-dev.json');
|
|
52
|
+
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
53
|
+
const MODELS_DEV_URL = 'https://models.dev/api.json';
|
|
54
|
+
|
|
55
|
+
declare const FLOWCODEX_MODELS_DEV: string | undefined;
|
|
56
|
+
|
|
57
|
+
function parseRawCatalog(raw: unknown): ModelInfo[] {
|
|
58
|
+
const data = raw as Record<string, unknown>;
|
|
59
|
+
if (!data || typeof data !== 'object') return [];
|
|
60
|
+
const models: ModelInfo[] = [];
|
|
61
|
+
for (const [providerId, provider] of Object.entries(data)) {
|
|
62
|
+
if (!provider || typeof provider !== 'object') continue;
|
|
63
|
+
const p = provider as Record<string, unknown>;
|
|
64
|
+
const npm = p.npm as string | undefined;
|
|
65
|
+
const rawModels = p.models as Record<string, unknown> | undefined;
|
|
66
|
+
if (!rawModels) continue;
|
|
67
|
+
for (const [modelId, model] of Object.entries(rawModels)) {
|
|
68
|
+
if (!model || typeof model !== 'object') continue;
|
|
69
|
+
const m = model as Record<string, unknown>;
|
|
70
|
+
const limit = m.limit as Record<string, number> | undefined;
|
|
71
|
+
const cost = m.cost as Record<string, number> | undefined;
|
|
72
|
+
const modalities = m.modalities as Record<string, string[]> | undefined;
|
|
73
|
+
models.push({
|
|
74
|
+
id: modelId,
|
|
75
|
+
name: (m.name as string) ?? modelId,
|
|
76
|
+
provider: providerId,
|
|
77
|
+
npm,
|
|
78
|
+
reasoning: (m.reasoning as boolean) ?? false,
|
|
79
|
+
reasoning_options: m.reasoning_options as unknown[] | undefined,
|
|
80
|
+
limit: {
|
|
81
|
+
context: limit?.context ?? 200_000,
|
|
82
|
+
output: limit?.output ?? 8_192,
|
|
83
|
+
},
|
|
84
|
+
cost: cost ? {
|
|
85
|
+
input: cost.input ?? 0,
|
|
86
|
+
output: cost.output ?? 0,
|
|
87
|
+
cache_read: cost.cache_read,
|
|
88
|
+
cache_write: cost.cache_write,
|
|
89
|
+
} : undefined,
|
|
90
|
+
tool_call: (m.tool_call as boolean) ?? false,
|
|
91
|
+
modalities: {
|
|
92
|
+
input: modalities?.input ?? ['text'],
|
|
93
|
+
output: modalities?.output ?? ['text'],
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return models;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class DefaultCatalogParser implements CatalogParser {
|
|
102
|
+
private cached: CatalogEntry | undefined;
|
|
103
|
+
private disableNetwork: boolean;
|
|
104
|
+
|
|
105
|
+
constructor(opts: { disableNetwork?: boolean } = {}) {
|
|
106
|
+
this.disableNetwork = opts.disableNetwork ?? false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async load(): Promise<CatalogEntry> {
|
|
110
|
+
if (this.cached) return this.cached;
|
|
111
|
+
|
|
112
|
+
const cached = await this.loadFromCache();
|
|
113
|
+
if (cached) {
|
|
114
|
+
this.cached = cached;
|
|
115
|
+
return cached;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const snapshot = this.loadFromSnapshot();
|
|
119
|
+
if (snapshot) {
|
|
120
|
+
this.cached = snapshot;
|
|
121
|
+
if (!this.disableNetwork) {
|
|
122
|
+
void this.refreshInBackground();
|
|
123
|
+
}
|
|
124
|
+
return snapshot;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!this.disableNetwork) {
|
|
128
|
+
const network = await this.loadFromNetwork();
|
|
129
|
+
if (network) {
|
|
130
|
+
this.cached = network;
|
|
131
|
+
return network;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
this.cached = { models: [], fetchedAt: 0, source: 'snapshot' };
|
|
136
|
+
return this.cached;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
getModels(provider?: string): ModelInfo[] {
|
|
140
|
+
if (!this.cached) return [];
|
|
141
|
+
if (provider) {
|
|
142
|
+
return this.cached.models.filter((m) => m.provider === provider);
|
|
143
|
+
}
|
|
144
|
+
return this.cached.models;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
getModel(id: string): ModelInfo | undefined {
|
|
148
|
+
return this.cached?.models.find((m) => m.id === id);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
getCheapestModel(providerId: string, excludeModelId: string): ModelInfo | undefined {
|
|
152
|
+
const models = this.getModels(providerId);
|
|
153
|
+
const candidates = models.filter(
|
|
154
|
+
(m) =>
|
|
155
|
+
m.id !== excludeModelId &&
|
|
156
|
+
m.tool_call === true &&
|
|
157
|
+
m.cost !== undefined &&
|
|
158
|
+
m.limit.context >= 16_000,
|
|
159
|
+
);
|
|
160
|
+
if (candidates.length === 0) return undefined;
|
|
161
|
+
candidates.sort((a, b) => a.cost!.output - b.cost!.output);
|
|
162
|
+
return candidates[0];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private async loadFromCache(): Promise<CatalogEntry | undefined> {
|
|
166
|
+
try {
|
|
167
|
+
const raw = await fsp.readFile(CACHE_FILE, 'utf-8');
|
|
168
|
+
const data = JSON.parse(raw);
|
|
169
|
+
if (Date.now() - data.fetchedAt > CACHE_TTL_MS) return undefined;
|
|
170
|
+
return {
|
|
171
|
+
models: parseRawCatalog(data.catalog),
|
|
172
|
+
fetchedAt: data.fetchedAt,
|
|
173
|
+
source: 'cache',
|
|
174
|
+
};
|
|
175
|
+
} catch {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private loadFromSnapshot(): CatalogEntry | undefined {
|
|
181
|
+
try {
|
|
182
|
+
const raw = typeof FLOWCODEX_MODELS_DEV !== 'undefined' ? FLOWCODEX_MODELS_DEV : undefined;
|
|
183
|
+
if (!raw) return undefined;
|
|
184
|
+
return {
|
|
185
|
+
models: parseRawCatalog(JSON.parse(raw)),
|
|
186
|
+
fetchedAt: 0,
|
|
187
|
+
source: 'snapshot',
|
|
188
|
+
};
|
|
189
|
+
} catch {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private async loadFromNetwork(): Promise<CatalogEntry | undefined> {
|
|
195
|
+
try {
|
|
196
|
+
const res = await fetch(MODELS_DEV_URL);
|
|
197
|
+
if (!res.ok) return undefined;
|
|
198
|
+
const raw = await res.text();
|
|
199
|
+
const catalog = JSON.parse(raw);
|
|
200
|
+
await fsp.mkdir(CACHE_DIR, { recursive: true });
|
|
201
|
+
await fsp.writeFile(CACHE_FILE, JSON.stringify({ catalog, fetchedAt: Date.now() }), 'utf-8');
|
|
202
|
+
return {
|
|
203
|
+
models: parseRawCatalog(catalog),
|
|
204
|
+
fetchedAt: Date.now(),
|
|
205
|
+
source: 'network',
|
|
206
|
+
};
|
|
207
|
+
} catch {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private async refreshInBackground(): Promise<void> {
|
|
213
|
+
const fresh = await this.loadFromNetwork();
|
|
214
|
+
if (fresh) {
|
|
215
|
+
this.cached = fresh;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { DefaultPathResolver } from './path-resolver.js';
|
|
2
|
+
export type { PathResolver } from './path-resolver.js';
|
|
3
|
+
export { safeResolve, safeResolveReal } from './path-resolver.js';
|
|
4
|
+
export { DefaultRetryPolicy } from './retry-policy.js';
|
|
5
|
+
export type { RetryPolicy, ProviderError } from './retry-policy.js';
|
|
6
|
+
export { parseRetryAfter } from './retry-policy.js';
|
|
7
|
+
export { NoopTracer, NoopSpan } from './tracer.js';
|
|
8
|
+
export type { Tracer, Span } from './tracer.js';
|
|
9
|
+
export { DefaultTokenCounter } from './token-counter.js';
|
|
10
|
+
export type { TokenCounter, Usage, ModelPricing } from './token-counter.js';
|
|
11
|
+
export { DefaultSecretScrubber } from './secret-scrubber.js';
|
|
12
|
+
export type { SecretScrubber } from './secret-scrubber.js';
|
|
13
|
+
export { DefaultCatalogParser, classifyFamily } from './catalog-parser.js';
|
|
14
|
+
export type { CatalogParser, CatalogEntry, ModelInfo, WireFamily } from './catalog-parser.js';
|
|
15
|
+
export { createProvider, setProviderConstructors, resolveFamily } from './provider-factory.js';
|
|
16
|
+
export type { ProviderConstructors, ProviderConfigEntry, CreateProviderOptions, FlowCodexLikeConfig } from './provider-factory.js';
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { promises as fsp } from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { FsError, ERROR_CODES } from '../types/errors.js';
|
|
4
|
+
|
|
5
|
+
export interface PathResolver {
|
|
6
|
+
readonly projectRoot: string;
|
|
7
|
+
readonly cwd: string;
|
|
8
|
+
resolve(input: string): string;
|
|
9
|
+
isInsideRoot(absPath: string): boolean;
|
|
10
|
+
ensureInsideRoot(absPath: string): string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const PROJECT_MARKERS = [
|
|
14
|
+
'.git',
|
|
15
|
+
'package.json',
|
|
16
|
+
'pnpm-workspace.yaml',
|
|
17
|
+
'go.mod',
|
|
18
|
+
'Cargo.toml',
|
|
19
|
+
'pyproject.toml',
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
export class DefaultPathResolver implements PathResolver {
|
|
23
|
+
readonly projectRoot: string;
|
|
24
|
+
readonly cwd: string;
|
|
25
|
+
|
|
26
|
+
constructor(opts: { projectRoot?: string; cwd?: string } = {}) {
|
|
27
|
+
this.cwd = opts.cwd ?? process.cwd();
|
|
28
|
+
this.projectRoot = opts.projectRoot ?? this.detectProjectRoot(this.cwd);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
detectProjectRoot(start: string): string {
|
|
32
|
+
const home = require('node:os').homedir();
|
|
33
|
+
let dir = path.resolve(start);
|
|
34
|
+
for (;;) {
|
|
35
|
+
for (const marker of PROJECT_MARKERS) {
|
|
36
|
+
if (require('node:fs').existsSync(path.join(dir, marker))) {
|
|
37
|
+
return dir;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const parent = path.dirname(dir);
|
|
41
|
+
if (parent === dir || dir === home) break;
|
|
42
|
+
dir = parent;
|
|
43
|
+
}
|
|
44
|
+
return path.resolve(start);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
resolve(input: string): string {
|
|
48
|
+
const resolved = path.resolve(this.cwd, input);
|
|
49
|
+
try {
|
|
50
|
+
return require('node:fs').realpathSync(resolved);
|
|
51
|
+
} catch {
|
|
52
|
+
return path.normalize(resolved);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
isInsideRoot(absPath: string): boolean {
|
|
57
|
+
const rel = path.relative(this.projectRoot, absPath);
|
|
58
|
+
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
ensureInsideRoot(absPath: string): string {
|
|
62
|
+
if (!this.isInsideRoot(absPath)) {
|
|
63
|
+
throw new FsError({
|
|
64
|
+
message: `Path "${path.basename(absPath)}" is outside the project root`,
|
|
65
|
+
code: ERROR_CODES.FS_PATH_ESCAPE,
|
|
66
|
+
path: absPath,
|
|
67
|
+
context: { projectRoot: this.projectRoot },
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return absPath;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function safeResolve(
|
|
75
|
+
input: string,
|
|
76
|
+
projectRoot: string,
|
|
77
|
+
cwd: string,
|
|
78
|
+
): Promise<string> {
|
|
79
|
+
const resolved = path.resolve(cwd, input);
|
|
80
|
+
const rel = path.relative(projectRoot, resolved);
|
|
81
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
82
|
+
throw new FsError({
|
|
83
|
+
message: `Path "${path.basename(resolved)}" is outside the project root`,
|
|
84
|
+
code: ERROR_CODES.FS_PATH_ESCAPE,
|
|
85
|
+
path: resolved,
|
|
86
|
+
context: { projectRoot },
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return resolved;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function safeResolveReal(
|
|
93
|
+
input: string,
|
|
94
|
+
projectRoot: string,
|
|
95
|
+
cwd: string,
|
|
96
|
+
): Promise<string> {
|
|
97
|
+
const resolved = await safeResolve(input, projectRoot, cwd);
|
|
98
|
+
const realRoot = await fsp.realpath(projectRoot).catch(() => path.resolve(projectRoot));
|
|
99
|
+
|
|
100
|
+
let probe = resolved;
|
|
101
|
+
for (;;) {
|
|
102
|
+
try {
|
|
103
|
+
const real = await fsp.realpath(probe);
|
|
104
|
+
const rel = path.relative(realRoot, real);
|
|
105
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
106
|
+
throw new FsError({
|
|
107
|
+
message: `Symlink escape detected: "${path.basename(resolved)}" resolves outside the project root`,
|
|
108
|
+
code: ERROR_CODES.FS_PATH_ESCAPE,
|
|
109
|
+
path: resolved,
|
|
110
|
+
context: { realRoot, real },
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
break;
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (err instanceof FsError) throw err;
|
|
116
|
+
const parent = path.dirname(probe);
|
|
117
|
+
if (parent === probe) break;
|
|
118
|
+
probe = parent;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return resolved;
|
|
123
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { ERROR_CODES, FlowCodexError } from '../types/errors.js';
|
|
2
|
+
import type { Provider } from '../types/provider.js';
|
|
3
|
+
import { type CatalogParser, classifyFamily, type WireFamily } from './catalog-parser.js';
|
|
4
|
+
|
|
5
|
+
export interface ProviderConfigEntry {
|
|
6
|
+
baseUrl?: string | undefined;
|
|
7
|
+
family?: string | undefined;
|
|
8
|
+
env?: string | undefined;
|
|
9
|
+
extraHeaders?: Record<string, string> | undefined;
|
|
10
|
+
extraBody?: Record<string, unknown> | undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface FlowCodexLikeConfig {
|
|
14
|
+
providers: Record<string, ProviderConfigEntry>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CreateProviderOptions {
|
|
18
|
+
providerId: string;
|
|
19
|
+
apiKey: string;
|
|
20
|
+
config: FlowCodexLikeConfig;
|
|
21
|
+
catalog: CatalogParser;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ProviderConstructors {
|
|
25
|
+
anthropic: new (opts: { apiKey: string; baseUrl?: string }) => Provider;
|
|
26
|
+
openai: new (opts: {
|
|
27
|
+
apiKey: string;
|
|
28
|
+
baseUrl?: string;
|
|
29
|
+
organization?: string;
|
|
30
|
+
project?: string;
|
|
31
|
+
}) => Provider;
|
|
32
|
+
openaiCompatible: new (opts: {
|
|
33
|
+
apiKey: string;
|
|
34
|
+
baseUrl: string;
|
|
35
|
+
extraHeaders?: Record<string, string>;
|
|
36
|
+
extraBody?: Record<string, unknown>;
|
|
37
|
+
}) => Provider;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let constructors: ProviderConstructors | undefined;
|
|
41
|
+
|
|
42
|
+
export function setProviderConstructors(c: ProviderConstructors): void {
|
|
43
|
+
constructors = c;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createProvider(opts: CreateProviderOptions): Provider {
|
|
47
|
+
const family = resolveFamily(opts.providerId, opts.config, opts.catalog);
|
|
48
|
+
const entry = opts.config.providers[opts.providerId];
|
|
49
|
+
const baseUrl = entry?.baseUrl;
|
|
50
|
+
const cons = constructors;
|
|
51
|
+
if (!cons) {
|
|
52
|
+
throw new FlowCodexError({
|
|
53
|
+
message:
|
|
54
|
+
'Provider constructors not registered. Call setProviderConstructors() at CLI startup.',
|
|
55
|
+
code: ERROR_CODES.PROVIDER_UNSUPPORTED,
|
|
56
|
+
subsystem: 'provider',
|
|
57
|
+
severity: 'fatal',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const anthropicOpts: { apiKey: string; baseUrl?: string } = { apiKey: opts.apiKey };
|
|
61
|
+
if (baseUrl !== undefined) anthropicOpts.baseUrl = baseUrl;
|
|
62
|
+
const openaiOpts: { apiKey: string; baseUrl?: string; organization?: string; project?: string } =
|
|
63
|
+
{ apiKey: opts.apiKey };
|
|
64
|
+
if (baseUrl !== undefined) openaiOpts.baseUrl = baseUrl;
|
|
65
|
+
const compatibleOpts: {
|
|
66
|
+
apiKey: string;
|
|
67
|
+
baseUrl: string;
|
|
68
|
+
extraHeaders?: Record<string, string>;
|
|
69
|
+
extraBody?: Record<string, unknown>;
|
|
70
|
+
} = {
|
|
71
|
+
apiKey: opts.apiKey,
|
|
72
|
+
baseUrl: baseUrl ?? '',
|
|
73
|
+
};
|
|
74
|
+
if (entry?.extraHeaders !== undefined) compatibleOpts.extraHeaders = entry.extraHeaders;
|
|
75
|
+
if (entry?.extraBody !== undefined) compatibleOpts.extraBody = entry.extraBody;
|
|
76
|
+
switch (family) {
|
|
77
|
+
case 'anthropic':
|
|
78
|
+
return new cons.anthropic(anthropicOpts);
|
|
79
|
+
case 'openai':
|
|
80
|
+
return new cons.openai(openaiOpts);
|
|
81
|
+
case 'openai-compatible':
|
|
82
|
+
return new cons.openaiCompatible(compatibleOpts);
|
|
83
|
+
case 'google':
|
|
84
|
+
throw new FlowCodexError({
|
|
85
|
+
message: `Provider "${opts.providerId}" (google family) is not wired in v0.2.0. Tracked for a v0.2.x patch.`,
|
|
86
|
+
code: ERROR_CODES.PROVIDER_NOT_WIRED,
|
|
87
|
+
subsystem: 'provider',
|
|
88
|
+
severity: 'error',
|
|
89
|
+
});
|
|
90
|
+
default:
|
|
91
|
+
throw new FlowCodexError({
|
|
92
|
+
message: `Provider "${opts.providerId}" is not supported. Run \`flowcodex auth\` to see wired providers.`,
|
|
93
|
+
code: ERROR_CODES.PROVIDER_UNSUPPORTED,
|
|
94
|
+
subsystem: 'provider',
|
|
95
|
+
severity: 'error',
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function resolveFamily(
|
|
101
|
+
providerId: string,
|
|
102
|
+
config: FlowCodexLikeConfig,
|
|
103
|
+
catalog: CatalogParser,
|
|
104
|
+
): WireFamily | 'unsupported' {
|
|
105
|
+
const explicit = config.providers[providerId]?.family;
|
|
106
|
+
if (explicit) {
|
|
107
|
+
return explicit as WireFamily | 'unsupported';
|
|
108
|
+
}
|
|
109
|
+
const models = catalog.getModels(providerId);
|
|
110
|
+
if (models.length > 0) {
|
|
111
|
+
const npm = models[0]?.npm;
|
|
112
|
+
const fam = classifyFamily(npm);
|
|
113
|
+
if (fam !== 'unsupported') return fam;
|
|
114
|
+
}
|
|
115
|
+
return 'unsupported';
|
|
116
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface CompatibilityQuirks {
|
|
2
|
+
stripCacheControl?: boolean | undefined;
|
|
3
|
+
systemAsMessage?: boolean | undefined;
|
|
4
|
+
flattenContentToString?: boolean | undefined;
|
|
5
|
+
preserveToolCallIds?: boolean | undefined;
|
|
6
|
+
parallelToolsDisabled?: boolean | undefined;
|
|
7
|
+
jsonArgumentsBuggy?: boolean | undefined;
|
|
8
|
+
emptyToolCallContent?: 'null' | 'empty_string' | undefined;
|
|
9
|
+
reasoningContentEcho?: boolean | undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const PROVIDER_PRESETS: Record<string, CompatibilityQuirks> = {
|
|
13
|
+
deepseek: { reasoningContentEcho: true, jsonArgumentsBuggy: true },
|
|
14
|
+
groq: { jsonArgumentsBuggy: true },
|
|
15
|
+
openrouter: {},
|
|
16
|
+
ollama: { emptyToolCallContent: 'null', parallelToolsDisabled: true },
|
|
17
|
+
vllm: { emptyToolCallContent: 'null', parallelToolsDisabled: true },
|
|
18
|
+
lmstudio: { emptyToolCallContent: 'null', parallelToolsDisabled: true },
|
|
19
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export interface ProviderError {
|
|
2
|
+
status: number;
|
|
3
|
+
message: string;
|
|
4
|
+
retryable: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface RetryPolicy {
|
|
8
|
+
shouldRetry(err: ProviderError, attempt: number): boolean;
|
|
9
|
+
maxAttempts(err: ProviderError): number;
|
|
10
|
+
delayMs(attempt: number, err: ProviderError): number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DEFAULTS: Record<number, { max: number; retryable: boolean }> = {
|
|
14
|
+
429: { max: 5, retryable: true },
|
|
15
|
+
529: { max: 3, retryable: true },
|
|
16
|
+
500: { max: 3, retryable: true },
|
|
17
|
+
502: { max: 3, retryable: true },
|
|
18
|
+
503: { max: 3, retryable: true },
|
|
19
|
+
504: { max: 3, retryable: true },
|
|
20
|
+
599: { max: 2, retryable: true },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const NON_RETRYABLE = new Set([400, 401, 403, 404, 422]);
|
|
24
|
+
|
|
25
|
+
export class DefaultRetryPolicy implements RetryPolicy {
|
|
26
|
+
shouldRetry(err: ProviderError, attempt: number): boolean {
|
|
27
|
+
if (NON_RETRYABLE.has(err.status)) return false;
|
|
28
|
+
if (err.status >= 500 || err.status === 429 || err.status === 599) {
|
|
29
|
+
return attempt < this.maxAttempts(err);
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
maxAttempts(err: ProviderError): number {
|
|
35
|
+
return DEFAULTS[err.status]?.max ?? 3;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
delayMs(attempt: number, _err: ProviderError): number {
|
|
39
|
+
const exp = Math.min(30_000, 1000 * Math.pow(2, attempt));
|
|
40
|
+
const jitter = Math.floor(Math.random() * 1000);
|
|
41
|
+
return exp + jitter;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function parseRetryAfter(header: string | undefined): number | undefined {
|
|
46
|
+
if (!header) return undefined;
|
|
47
|
+
const seconds = parseInt(header, 10);
|
|
48
|
+
if (!Number.isNaN(seconds)) return Math.min(120_000, Math.max(1_000, seconds * 1000));
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export interface SecretScrubber {
|
|
2
|
+
scrub(text: string): string;
|
|
3
|
+
scrubObject<T>(obj: T): T;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
interface Pattern {
|
|
7
|
+
type: string;
|
|
8
|
+
regex: RegExp;
|
|
9
|
+
anchors: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const PATTERNS: Pattern[] = [
|
|
13
|
+
{ type: 'anthropic_key', regex: /sk-ant-[A-Za-z0-9_-]{20,}/, anchors: ['sk-ant-'] },
|
|
14
|
+
{ type: 'openai_key', regex: /sk-[A-Za-z0-9]{20,}/, anchors: ['sk-'] },
|
|
15
|
+
{ type: 'github_pat', regex: /ghp_[A-Za-z0-9]{20,}/, anchors: ['ghp_'] },
|
|
16
|
+
{ type: 'github_pat_v2', regex: /github_pat_[A-Za-z0-9_]{20,}/, anchors: ['github_pat_'] },
|
|
17
|
+
{ type: 'aws_access_key', regex: /AKIA[0-9A-Z]{16}/, anchors: ['AKIA'] },
|
|
18
|
+
{ type: 'gcp_key', regex: /AIza[0-9A-Za-z_-]{35}/, anchors: ['AIza'] },
|
|
19
|
+
{ type: 'slack_token', regex: /xox[baprs]-[0-9A-Za-z-]+/, anchors: ['xox'] },
|
|
20
|
+
{ type: 'stripe_key', regex: /sk_live_[0-9A-Za-z]{24,}/, anchors: ['sk_live_'] },
|
|
21
|
+
{ type: 'twilio_sid', regex: /AC[0-9a-f]{32}/, anchors: ['AC'] },
|
|
22
|
+
{ type: 'telegram_bot_token', regex: /\d{6,12}:AAH[0-9A-Za-z_-]{30,}/, anchors: [':AAH', '/bot'] },
|
|
23
|
+
{ type: 'jwt', regex: /eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/, anchors: ['eyJ'] },
|
|
24
|
+
{ type: 'private_key', regex: /-----BEGIN[A-Z ]+-----[\s\S]*?-----END[A-Z ]+-----/, anchors: ['-----BEGIN'] },
|
|
25
|
+
{ type: 'mongodb_uri', regex: /mongodb(\+srv)?:\/\/[^\s"'<>]+/, anchors: ['mongodb://', 'mongodb+srv://'] },
|
|
26
|
+
{ type: 'postgres_uri', regex: /postgres(ql)?:\/\/[^\s"'<>]+/, anchors: ['postgres://', 'postgresql://'] },
|
|
27
|
+
{ type: 'mysql_uri', regex: /mysql:\/\/[^\s"'<>]+/, anchors: ['mysql://'] },
|
|
28
|
+
{ type: 'redis_uri', regex: /rediss?:\/\/[^\s"'<>]+/, anchors: ['redis://', 'rediss://'] },
|
|
29
|
+
{ type: 'bearer_token', regex: /Bearer\s+[A-Za-z0-9_.~+/=-]{20,}/, anchors: ['Bearer '] },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const ALL_ANCHORS = PATTERNS.flatMap((p) => p.anchors);
|
|
33
|
+
|
|
34
|
+
const HIGH_ENTROPY_ENV = /([A-Z_][A-Z0-9_]*)=(["']?)([A-Za-z0-9+/=_-]{20,})\2/g;
|
|
35
|
+
|
|
36
|
+
export class DefaultSecretScrubber implements SecretScrubber {
|
|
37
|
+
scrub(text: string): string {
|
|
38
|
+
if (!this.hasCredentialAnchors(text)) return text;
|
|
39
|
+
let result = text;
|
|
40
|
+
for (const p of PATTERNS) {
|
|
41
|
+
result = result.replace(p.regex, `[REDACTED:${p.type}]`);
|
|
42
|
+
}
|
|
43
|
+
result = result.replace(HIGH_ENTROPY_ENV, (_m, name, _q, _val) => `${name}=[REDACTED:high_entropy_env]`);
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
scrubObject<T>(obj: T): T {
|
|
48
|
+
if (typeof obj === 'string') return this.scrub(obj) as T;
|
|
49
|
+
if (obj === null || obj === undefined) return obj;
|
|
50
|
+
if (Array.isArray(obj)) return obj.map((v) => this.scrubObject(v)) as T;
|
|
51
|
+
if (typeof obj === 'object') {
|
|
52
|
+
const result: Record<string, unknown> = {};
|
|
53
|
+
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
|
54
|
+
result[k] = this.scrubObject(v);
|
|
55
|
+
}
|
|
56
|
+
return result as T;
|
|
57
|
+
}
|
|
58
|
+
return obj;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
hasCredentialAnchors(text: string): boolean {
|
|
62
|
+
for (const anchor of ALL_ANCHORS) {
|
|
63
|
+
if (text.includes(anchor)) return true;
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|