@graphorin/provider 0.6.0 → 0.7.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/CHANGELOG.md +42 -0
- package/README.md +8 -3
- package/dist/adapters/llamacpp-server.d.ts +1 -1
- package/dist/adapters/llamacpp-server.js.map +1 -1
- package/dist/adapters/ollama.d.ts.map +1 -1
- package/dist/adapters/ollama.js +19 -6
- package/dist/adapters/ollama.js.map +1 -1
- package/dist/adapters/vercel.d.ts +1 -1
- package/dist/adapters/vercel.d.ts.map +1 -1
- package/dist/adapters/vercel.js +111 -33
- package/dist/adapters/vercel.js.map +1 -1
- package/dist/errors/errors.d.ts +1 -1
- package/dist/errors/errors.js +1 -1
- package/dist/errors/errors.js.map +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -8
- package/dist/index.js.map +1 -1
- package/dist/internal/http.js +111 -7
- package/dist/internal/http.js.map +1 -1
- package/dist/internal/openai-shaped.js +21 -4
- package/dist/internal/openai-shaped.js.map +1 -1
- package/dist/middleware/with-fallback.js +1 -1
- package/dist/middleware/with-rate-limit.d.ts +21 -8
- package/dist/middleware/with-rate-limit.d.ts.map +1 -1
- package/dist/middleware/with-rate-limit.js +65 -12
- package/dist/middleware/with-rate-limit.js.map +1 -1
- package/dist/middleware/with-retry.js +1 -1
- package/dist/package.js +6 -0
- package/dist/package.js.map +1 -0
- package/package.json +18 -16
- package/src/adapters/index.ts +14 -0
- package/src/adapters/llamacpp-server.ts +102 -0
- package/src/adapters/ollama.ts +382 -0
- package/src/adapters/openai-compatible.ts +95 -0
- package/src/adapters/vercel-messages.ts +308 -0
- package/src/adapters/vercel.ts +706 -0
- package/src/counters/anthropic-wire.ts +199 -0
- package/src/counters/anthropic.ts +114 -0
- package/src/counters/bedrock.ts +46 -0
- package/src/counters/dispatcher.ts +127 -0
- package/src/counters/global.ts +39 -0
- package/src/counters/google.ts +46 -0
- package/src/counters/heuristic.ts +107 -0
- package/src/counters/index.ts +35 -0
- package/src/counters/js-tiktoken.ts +135 -0
- package/src/counters/serialize.ts +85 -0
- package/src/errors/errors.ts +316 -0
- package/src/errors/index.ts +20 -0
- package/src/index.ts +42 -0
- package/src/internal/abort.ts +30 -0
- package/src/internal/http.ts +388 -0
- package/src/internal/openai-shaped.ts +555 -0
- package/src/internal/sse.ts +112 -0
- package/src/internal/url-utils.ts +20 -0
- package/src/middleware/compose.ts +213 -0
- package/src/middleware/index.ts +37 -0
- package/src/middleware/production-hook.ts +47 -0
- package/src/middleware/with-cost-limit.ts +131 -0
- package/src/middleware/with-cost-tracking.ts +216 -0
- package/src/middleware/with-fallback.ts +157 -0
- package/src/middleware/with-rate-limit.ts +306 -0
- package/src/middleware/with-redaction.ts +671 -0
- package/src/middleware/with-retry.ts +274 -0
- package/src/middleware/with-tracing.ts +117 -0
- package/src/model-tier/classify.ts +125 -0
- package/src/model-tier/index.ts +11 -0
- package/src/provider.ts +121 -0
- package/src/reasoning/apply-policy.ts +89 -0
- package/src/reasoning/classify-contract.ts +120 -0
- package/src/reasoning/index.ts +21 -0
- package/src/reasoning/retention.ts +64 -0
- package/src/tool-examples.ts +54 -0
- package/src/trust/classify-local-provider.ts +254 -0
- package/src/trust/index.ts +14 -0
- package/dist/adapters/index.js +0 -6
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `applyReasoningPolicy` - given a list of `Message`s and the resolved
|
|
3
|
+
* {@link ReasoningRetention}, return a transformed list with
|
|
4
|
+
* `reasoning` content parts treated according to the policy:
|
|
5
|
+
*
|
|
6
|
+
* - `'strip'` - drop every `reasoning` content part.
|
|
7
|
+
* - `'pass-through-claude'` - keep parts that carry Anthropic-shaped
|
|
8
|
+
* metadata (`provider === 'anthropic'` OR a `signature` field);
|
|
9
|
+
* drop the rest.
|
|
10
|
+
* - `'pass-through-all'` - keep every `reasoning` content part
|
|
11
|
+
* unchanged.
|
|
12
|
+
*
|
|
13
|
+
* The transformation is shallow: only the `assistant` role's content
|
|
14
|
+
* arrays are inspected (the only role that legitimately carries
|
|
15
|
+
* reasoning blocks).
|
|
16
|
+
*
|
|
17
|
+
* @packageDocumentation
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type {
|
|
21
|
+
AssistantMessage,
|
|
22
|
+
Message,
|
|
23
|
+
MessageContent,
|
|
24
|
+
ReasoningContent,
|
|
25
|
+
ReasoningRetention,
|
|
26
|
+
} from '@graphorin/core';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Inputs to {@link applyReasoningPolicy}.
|
|
30
|
+
*
|
|
31
|
+
* @stable
|
|
32
|
+
*/
|
|
33
|
+
export interface ApplyReasoningPolicyInput {
|
|
34
|
+
readonly messages: ReadonlyArray<Message>;
|
|
35
|
+
readonly retention: ReasoningRetention;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Apply the resolved retention policy to the provided messages.
|
|
40
|
+
*
|
|
41
|
+
* @stable
|
|
42
|
+
*/
|
|
43
|
+
export function applyReasoningPolicy(input: ApplyReasoningPolicyInput): ReadonlyArray<Message> {
|
|
44
|
+
if (input.retention === 'pass-through-all') return input.messages;
|
|
45
|
+
return input.messages.map((msg) => {
|
|
46
|
+
if (msg.role !== 'assistant') return msg;
|
|
47
|
+
return rewriteAssistantMessage(msg, input.retention);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function rewriteAssistantMessage(
|
|
52
|
+
msg: AssistantMessage,
|
|
53
|
+
retention: ReasoningRetention,
|
|
54
|
+
): AssistantMessage {
|
|
55
|
+
const content = msg.content;
|
|
56
|
+
if (typeof content === 'string') return msg;
|
|
57
|
+
const filtered: MessageContent[] = [];
|
|
58
|
+
let mutated = false;
|
|
59
|
+
for (const part of content) {
|
|
60
|
+
if (part.type !== 'reasoning') {
|
|
61
|
+
filtered.push(part);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (retention === 'strip') {
|
|
65
|
+
mutated = true;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (retention === 'pass-through-claude') {
|
|
69
|
+
if (isAnthropicShapedReasoning(part)) {
|
|
70
|
+
filtered.push(part);
|
|
71
|
+
} else {
|
|
72
|
+
mutated = true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!mutated) return msg;
|
|
77
|
+
return {
|
|
78
|
+
...msg,
|
|
79
|
+
content: filtered,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isAnthropicShapedReasoning(part: ReasoningContent): boolean {
|
|
84
|
+
const meta = part.meta;
|
|
85
|
+
if (meta === undefined) return false;
|
|
86
|
+
if (meta.provider === 'anthropic') return true;
|
|
87
|
+
if (typeof meta.signature === 'string' && meta.signature.length > 0) return true;
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `inferReasoningContract` - derive the canonical
|
|
3
|
+
* {@link ReasoningContract} value for a model id. Adapters that wrap
|
|
4
|
+
* cloud-LLM language-model values call this at construction time so
|
|
5
|
+
* the runtime can pick the correct
|
|
6
|
+
* {@link import('@graphorin/core').ReasoningRetention} default.
|
|
7
|
+
*
|
|
8
|
+
* The classifier is a pure function with the same shape as the
|
|
9
|
+
* `classifyModelTier` family: a small static rule table matched
|
|
10
|
+
* against the lowercased + prefix-stripped model id.
|
|
11
|
+
*
|
|
12
|
+
* @packageDocumentation
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ReasoningContract } from '@graphorin/core';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Single entry in the contract-classifier rule table.
|
|
19
|
+
*
|
|
20
|
+
* @stable
|
|
21
|
+
*/
|
|
22
|
+
export interface ReasoningContractRule {
|
|
23
|
+
readonly contract: ReasoningContract;
|
|
24
|
+
readonly pattern: RegExp;
|
|
25
|
+
/** Human-readable family label. */
|
|
26
|
+
readonly family: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Static rule table. Higher-specificity entries come first. Mirrors
|
|
31
|
+
* the per-family matrix documented for the provider layer.
|
|
32
|
+
*
|
|
33
|
+
* @stable
|
|
34
|
+
*/
|
|
35
|
+
export const REASONING_CONTRACT_RULES: readonly ReasoningContractRule[] = Object.freeze([
|
|
36
|
+
// Anthropic Claude families (and Bedrock-distributed equivalents).
|
|
37
|
+
{
|
|
38
|
+
contract: 'round-trip-required',
|
|
39
|
+
family: 'anthropic-claude',
|
|
40
|
+
pattern: /^claude(?:-\d|-)/,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
contract: 'round-trip-required',
|
|
44
|
+
family: 'bedrock-claude',
|
|
45
|
+
pattern: /^anthropic\.claude/,
|
|
46
|
+
},
|
|
47
|
+
// OpenAI hidden chain-of-thought reasoning models.
|
|
48
|
+
{ contract: 'hidden', family: 'openai-reasoning', pattern: /^o[1-9]\b/ },
|
|
49
|
+
{ contract: 'hidden', family: 'openai-reasoning-extended', pattern: /^o[1-9][a-z-]/ },
|
|
50
|
+
// Google Gemini reasoning families (the dedicated thinking variants;
|
|
51
|
+
// the regular pro / flash families do not require round-tripping).
|
|
52
|
+
{
|
|
53
|
+
contract: 'hidden',
|
|
54
|
+
family: 'gemini-reasoning',
|
|
55
|
+
pattern: /^gemini.*(?:thinking|reasoning)/,
|
|
56
|
+
},
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Inputs to {@link inferReasoningContract}.
|
|
61
|
+
*
|
|
62
|
+
* @stable
|
|
63
|
+
*/
|
|
64
|
+
export interface InferReasoningContractInput {
|
|
65
|
+
readonly modelId: string;
|
|
66
|
+
readonly provider?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Return the canonical {@link ReasoningContract} for a model id, or
|
|
71
|
+
* `'optional'` for unknown / Ollama / OpenAI-compatible families.
|
|
72
|
+
*
|
|
73
|
+
* @stable
|
|
74
|
+
*/
|
|
75
|
+
export function inferReasoningContract(input: InferReasoningContractInput): ReasoningContract {
|
|
76
|
+
if (typeof input.modelId !== 'string' || input.modelId.length === 0) {
|
|
77
|
+
return 'optional';
|
|
78
|
+
}
|
|
79
|
+
const provider = input.provider?.toLowerCase();
|
|
80
|
+
// Provider-explicit short-circuits - let cloud-LLM consumers tag
|
|
81
|
+
// their model with `provider: 'anthropic'` even when the modelId is
|
|
82
|
+
// a non-canonical alias (e.g. `legacy-thinking-router`). Prefix /
|
|
83
|
+
// substring matching because the AI SDK reports dotted provider ids
|
|
84
|
+
// (`'anthropic.messages'`, `'amazon-bedrock.messages'`), not the
|
|
85
|
+
// bare vendor name (core-provider-11).
|
|
86
|
+
if (
|
|
87
|
+
provider !== undefined &&
|
|
88
|
+
(provider.startsWith('anthropic') || provider.includes('bedrock'))
|
|
89
|
+
) {
|
|
90
|
+
return 'round-trip-required';
|
|
91
|
+
}
|
|
92
|
+
const normalised = stripPrefix(input.modelId.toLowerCase());
|
|
93
|
+
for (const rule of REASONING_CONTRACT_RULES) {
|
|
94
|
+
if (rule.pattern.test(normalised)) return rule.contract;
|
|
95
|
+
}
|
|
96
|
+
return 'optional';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Bedrock cross-region inference-profile prefix (`us.anthropic.claude-…`,
|
|
101
|
+
* the standard way to invoke Claude on Bedrock since 2025). Stripped
|
|
102
|
+
* before pattern matching so the `^anthropic\.claude` rules still fire
|
|
103
|
+
* (core-provider-11).
|
|
104
|
+
*/
|
|
105
|
+
const BEDROCK_REGION_PREFIX = /^(?:us|eu|apac|jp|au|us-gov)\./;
|
|
106
|
+
|
|
107
|
+
function stripPrefix(model: string): string {
|
|
108
|
+
const slash = model.indexOf('/');
|
|
109
|
+
if (slash !== -1) return model.slice(slash + 1).replace(BEDROCK_REGION_PREFIX, '');
|
|
110
|
+
const deRegioned = model.replace(BEDROCK_REGION_PREFIX, '');
|
|
111
|
+
// Bedrock ids end in ':<version>' (`anthropic.claude-...-v1:0`) - the
|
|
112
|
+
// colon there is a version separator, not a provider/model split, and
|
|
113
|
+
// the rule patterns are prefix-anchored so the suffix is harmless.
|
|
114
|
+
if (deRegioned.startsWith('anthropic.')) return deRegioned;
|
|
115
|
+
const colon = deRegioned.indexOf(':');
|
|
116
|
+
if (colon !== -1 && !deRegioned.startsWith('http')) {
|
|
117
|
+
return deRegioned.slice(colon + 1);
|
|
118
|
+
}
|
|
119
|
+
return deRegioned;
|
|
120
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reasoning-content lifecycle helpers.
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
type ApplyReasoningPolicyInput,
|
|
9
|
+
applyReasoningPolicy,
|
|
10
|
+
} from './apply-policy.js';
|
|
11
|
+
export {
|
|
12
|
+
type InferReasoningContractInput,
|
|
13
|
+
inferReasoningContract,
|
|
14
|
+
REASONING_CONTRACT_RULES,
|
|
15
|
+
type ReasoningContractRule,
|
|
16
|
+
} from './classify-contract.js';
|
|
17
|
+
export {
|
|
18
|
+
REASONING_RETENTION_DEFAULTS,
|
|
19
|
+
type ResolveReasoningRetentionInput,
|
|
20
|
+
resolveReasoningRetention,
|
|
21
|
+
} from './retention.js';
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reasoning-retention resolver - picks the effective
|
|
3
|
+
* {@link ReasoningRetention} value for a request given:
|
|
4
|
+
*
|
|
5
|
+
* 1. an explicit per-request `reasoningRetention` field (highest
|
|
6
|
+
* precedence - user override always wins),
|
|
7
|
+
* 2. an instance-level override supplied to `createProvider({...})`,
|
|
8
|
+
* and
|
|
9
|
+
* 3. the auto-detected default derived from the adapter's declared
|
|
10
|
+
* `capabilities.reasoningContract` (lowest precedence).
|
|
11
|
+
*
|
|
12
|
+
* @packageDocumentation
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ReasoningContract, ReasoningRetention } from '@graphorin/core';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Map from `ReasoningContract` to the conservative default
|
|
19
|
+
* `ReasoningRetention` value used when no override is supplied.
|
|
20
|
+
*
|
|
21
|
+
* @stable
|
|
22
|
+
*/
|
|
23
|
+
export const REASONING_RETENTION_DEFAULTS: Readonly<Record<ReasoningContract, ReasoningRetention>> =
|
|
24
|
+
Object.freeze({
|
|
25
|
+
hidden: 'strip',
|
|
26
|
+
'round-trip-required': 'pass-through-claude',
|
|
27
|
+
optional: 'strip',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Inputs to {@link resolveReasoningRetention}.
|
|
32
|
+
*
|
|
33
|
+
* @stable
|
|
34
|
+
*/
|
|
35
|
+
export interface ResolveReasoningRetentionInput {
|
|
36
|
+
/** Explicit per-request override (highest precedence). */
|
|
37
|
+
readonly requested?: ReasoningRetention;
|
|
38
|
+
/** Instance-level override supplied to `createProvider({...})`. */
|
|
39
|
+
readonly overridden?: ReasoningRetention;
|
|
40
|
+
/** Adapter-declared capability (lowest precedence). */
|
|
41
|
+
readonly contract?: ReasoningContract;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the effective {@link ReasoningRetention} value for a single
|
|
46
|
+
* request. The resolution is precedence-driven:
|
|
47
|
+
*
|
|
48
|
+
* 1. `requested` wins if defined.
|
|
49
|
+
* 2. `overridden` wins next.
|
|
50
|
+
* 3. The default for `contract` is used if the contract is known.
|
|
51
|
+
* 4. `'strip'` is the conservative fallback when no input is supplied.
|
|
52
|
+
*
|
|
53
|
+
* @stable
|
|
54
|
+
*/
|
|
55
|
+
export function resolveReasoningRetention(
|
|
56
|
+
input: ResolveReasoningRetentionInput,
|
|
57
|
+
): ReasoningRetention {
|
|
58
|
+
if (input.requested !== undefined) return input.requested;
|
|
59
|
+
if (input.overridden !== undefined) return input.overridden;
|
|
60
|
+
if (input.contract !== undefined) {
|
|
61
|
+
return REASONING_RETENTION_DEFAULTS[input.contract];
|
|
62
|
+
}
|
|
63
|
+
return 'strip';
|
|
64
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A1 (SOTA): fold a tool's worked `examples` into its model-facing description.
|
|
3
|
+
*
|
|
4
|
+
* `ToolDefinition` already carries `examples` on the wire (the agent projects
|
|
5
|
+
* them from `Tool.examples`), and the contract notes implementations MAY fold
|
|
6
|
+
* them into the description - but no adapter did, so the model never saw them.
|
|
7
|
+
* Anthropic reports complex-parameter accuracy jumps 72% → 90% with worked
|
|
8
|
+
* examples in the tool text. This renders them deterministically (so the tool
|
|
9
|
+
* spec stays prompt-cache-stable) and drops the now-redundant structured field.
|
|
10
|
+
*
|
|
11
|
+
* @packageDocumentation
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ToolDefinition, ToolDefinitionExample } from '@graphorin/core';
|
|
15
|
+
|
|
16
|
+
/** Render worked examples as a compact, deterministic text block. */
|
|
17
|
+
function renderExamplesSection(examples: ReadonlyArray<ToolDefinitionExample>): string {
|
|
18
|
+
const lines = examples.map((ex, i) => {
|
|
19
|
+
const comment = ex.comment !== undefined ? ` // ${ex.comment}` : '';
|
|
20
|
+
return `${i + 1}. input: ${JSON.stringify(ex.input)} -> output: ${JSON.stringify(ex.output)}${comment}`;
|
|
21
|
+
});
|
|
22
|
+
return `Examples:\n${lines.join('\n')}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Fold each tool's `examples` into its `description` and drop the structured
|
|
27
|
+
* field. Non-destructive: a tool with no examples is returned by reference, and
|
|
28
|
+
* the whole array is returned unchanged (same reference) when nothing folds - so
|
|
29
|
+
* callers can cheaply detect a no-op.
|
|
30
|
+
*/
|
|
31
|
+
export function foldToolExamples(
|
|
32
|
+
tools: ReadonlyArray<ToolDefinition>,
|
|
33
|
+
): ReadonlyArray<ToolDefinition> {
|
|
34
|
+
let changed = false;
|
|
35
|
+
const out = tools.map((tool): ToolDefinition => {
|
|
36
|
+
const examples = tool.examples;
|
|
37
|
+
if (examples === undefined || examples.length === 0) return tool;
|
|
38
|
+
changed = true;
|
|
39
|
+
const section = renderExamplesSection(examples);
|
|
40
|
+
const description =
|
|
41
|
+
tool.description !== undefined && tool.description.length > 0
|
|
42
|
+
? `${tool.description}\n\n${section}`
|
|
43
|
+
: section;
|
|
44
|
+
// Rebuild without `examples` (now in the description text), preserving every
|
|
45
|
+
// other field - notably `outputSchema` (A5).
|
|
46
|
+
return {
|
|
47
|
+
name: tool.name,
|
|
48
|
+
inputSchema: tool.inputSchema,
|
|
49
|
+
description,
|
|
50
|
+
...(tool.outputSchema !== undefined ? { outputSchema: tool.outputSchema } : {}),
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
return changed ? out : tools;
|
|
54
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared trust classifier for every `baseUrl`-driven local-LLM
|
|
3
|
+
* adapter. Emits one of four discriminant values per
|
|
4
|
+
* {@link LocalProviderTrust}:
|
|
5
|
+
*
|
|
6
|
+
* - `'loopback'` - the URL points at the same host as the
|
|
7
|
+
* running process (`localhost`, `127.0.0.0/8`, `::1`, or a
|
|
8
|
+
* `unix:///path` socket).
|
|
9
|
+
* - `'private'` - RFC 1918 / RFC 6598 / link-local /
|
|
10
|
+
* multicast-DNS-style hostname.
|
|
11
|
+
* - `'public-tls'` - public host AND `https://`.
|
|
12
|
+
* - `'public-cleartext'` - public host AND `http://`.
|
|
13
|
+
*
|
|
14
|
+
* The dispatcher is a pure function: it sees the URL string and
|
|
15
|
+
* nothing else. DNS resolution is deferred to the adapter layer
|
|
16
|
+
* (cross-cut DEC-149 § DNS-rebinding mitigation), where the resolved
|
|
17
|
+
* IP is re-classified on TTL expiry and the lowest-trust value wins.
|
|
18
|
+
*
|
|
19
|
+
* @packageDocumentation
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { LocalProviderTrust, Sensitivity } from '@graphorin/core';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Result of {@link classifyLocalProvider}. Carries both the trust
|
|
26
|
+
* class and a short human-readable reason for the WARN log produced
|
|
27
|
+
* by the consuming adapter.
|
|
28
|
+
*
|
|
29
|
+
* @stable
|
|
30
|
+
*/
|
|
31
|
+
export interface LocalProviderClassification {
|
|
32
|
+
readonly trust: LocalProviderTrust;
|
|
33
|
+
/** One-line reason - `'loopback IP 127.0.0.1'`, `'public IP 5.6.7.8 over HTTPS'`, ... */
|
|
34
|
+
readonly reason: string;
|
|
35
|
+
/** Default `acceptsSensitivity` for this trust class. */
|
|
36
|
+
readonly acceptsSensitivity: ReadonlyArray<Sensitivity>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Per-tier default sensitivity envelope. Lifted to a constant so
|
|
41
|
+
* downstream code (and tests) can import it without re-deriving the
|
|
42
|
+
* matrix.
|
|
43
|
+
*
|
|
44
|
+
* @stable
|
|
45
|
+
*/
|
|
46
|
+
export const SENSITIVITY_DEFAULTS_PER_TRUST: Readonly<
|
|
47
|
+
Record<LocalProviderTrust, ReadonlyArray<Sensitivity>>
|
|
48
|
+
> = Object.freeze({
|
|
49
|
+
loopback: Object.freeze(['public', 'internal', 'secret'] as const),
|
|
50
|
+
private: Object.freeze(['public', 'internal'] as const),
|
|
51
|
+
'public-tls': Object.freeze(['public'] as const),
|
|
52
|
+
'public-cleartext': Object.freeze([] as const),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Classify a URL string into one of the four {@link LocalProviderTrust}
|
|
57
|
+
* tiers. Throws `TypeError` if the URL is unparseable so adapters
|
|
58
|
+
* fail fast at construction time (programming error; not a runtime
|
|
59
|
+
* fault).
|
|
60
|
+
*
|
|
61
|
+
* @stable
|
|
62
|
+
*/
|
|
63
|
+
export function classifyLocalProvider(baseUrl: string): LocalProviderClassification {
|
|
64
|
+
if (typeof baseUrl !== 'string' || baseUrl.length === 0) {
|
|
65
|
+
throw new TypeError('classifyLocalProvider: baseUrl must be a non-empty string.');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (baseUrl.startsWith('unix://') || baseUrl.startsWith('unix:/')) {
|
|
69
|
+
return {
|
|
70
|
+
trust: 'loopback',
|
|
71
|
+
reason: `unix domain socket (${baseUrl})`,
|
|
72
|
+
acceptsSensitivity: SENSITIVITY_DEFAULTS_PER_TRUST.loopback,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let url: URL;
|
|
77
|
+
try {
|
|
78
|
+
url = new URL(baseUrl);
|
|
79
|
+
} catch (cause) {
|
|
80
|
+
throw new TypeError(`classifyLocalProvider: baseUrl '${baseUrl}' is not a valid URL.`, {
|
|
81
|
+
cause,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const protocol = url.protocol;
|
|
86
|
+
const hostname = url.hostname;
|
|
87
|
+
if (protocol !== 'http:' && protocol !== 'https:') {
|
|
88
|
+
// Custom protocols (e.g. ws://) are out of scope for the four
|
|
89
|
+
// bundled adapters; treat as private so a downstream WARN fires.
|
|
90
|
+
return {
|
|
91
|
+
trust: 'private',
|
|
92
|
+
reason: `non-HTTP protocol '${protocol}'`,
|
|
93
|
+
acceptsSensitivity: SENSITIVITY_DEFAULTS_PER_TRUST.private,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const ipv4 = parseIpv4(hostname);
|
|
98
|
+
if (ipv4 !== null) {
|
|
99
|
+
if (isLoopbackIpv4(ipv4)) {
|
|
100
|
+
return {
|
|
101
|
+
trust: 'loopback',
|
|
102
|
+
reason: `loopback IPv4 ${hostname}`,
|
|
103
|
+
acceptsSensitivity: SENSITIVITY_DEFAULTS_PER_TRUST.loopback,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (isPrivateIpv4(ipv4)) {
|
|
107
|
+
return {
|
|
108
|
+
trust: 'private',
|
|
109
|
+
reason: `private IPv4 ${hostname}`,
|
|
110
|
+
acceptsSensitivity: SENSITIVITY_DEFAULTS_PER_TRUST.private,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return classifyPublic(protocol, `public IPv4 ${hostname}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (isIpv6(hostname)) {
|
|
117
|
+
if (isLoopbackIpv6(hostname)) {
|
|
118
|
+
return {
|
|
119
|
+
trust: 'loopback',
|
|
120
|
+
reason: `loopback IPv6 ${hostname}`,
|
|
121
|
+
acceptsSensitivity: SENSITIVITY_DEFAULTS_PER_TRUST.loopback,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (isPrivateIpv6(hostname)) {
|
|
125
|
+
return {
|
|
126
|
+
trust: 'private',
|
|
127
|
+
reason: `private IPv6 ${hostname}`,
|
|
128
|
+
acceptsSensitivity: SENSITIVITY_DEFAULTS_PER_TRUST.private,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
return classifyPublic(protocol, `public IPv6 ${hostname}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (isLoopbackHostname(hostname)) {
|
|
135
|
+
return {
|
|
136
|
+
trust: 'loopback',
|
|
137
|
+
reason: `loopback hostname '${hostname}'`,
|
|
138
|
+
acceptsSensitivity: SENSITIVITY_DEFAULTS_PER_TRUST.loopback,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (isPrivateHostnameSuffix(hostname)) {
|
|
142
|
+
return {
|
|
143
|
+
trust: 'private',
|
|
144
|
+
reason: `private hostname suffix '${hostname}'`,
|
|
145
|
+
acceptsSensitivity: SENSITIVITY_DEFAULTS_PER_TRUST.private,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return classifyPublic(protocol, `public hostname '${hostname}'`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function classifyPublic(protocol: string, reason: string): LocalProviderClassification {
|
|
153
|
+
if (protocol === 'https:') {
|
|
154
|
+
return {
|
|
155
|
+
trust: 'public-tls',
|
|
156
|
+
reason: `${reason} over HTTPS`,
|
|
157
|
+
acceptsSensitivity: SENSITIVITY_DEFAULTS_PER_TRUST['public-tls'],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
trust: 'public-cleartext',
|
|
162
|
+
reason: `${reason} over HTTP`,
|
|
163
|
+
acceptsSensitivity: SENSITIVITY_DEFAULTS_PER_TRUST['public-cleartext'],
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parseIpv4(hostname: string): [number, number, number, number] | null {
|
|
168
|
+
const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname);
|
|
169
|
+
if (match === null) return null;
|
|
170
|
+
const a = Number(match[1]);
|
|
171
|
+
const b = Number(match[2]);
|
|
172
|
+
const c = Number(match[3]);
|
|
173
|
+
const d = Number(match[4]);
|
|
174
|
+
if (a > 255 || b > 255 || c > 255 || d > 255) return null;
|
|
175
|
+
return [a, b, c, d];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function isLoopbackIpv4(ip: readonly [number, number, number, number]): boolean {
|
|
179
|
+
return ip[0] === 127;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function isPrivateIpv4(ip: readonly [number, number, number, number]): boolean {
|
|
183
|
+
// RFC 1918
|
|
184
|
+
if (ip[0] === 10) return true;
|
|
185
|
+
if (ip[0] === 192 && ip[1] === 168) return true;
|
|
186
|
+
if (ip[0] === 172 && ip[1] >= 16 && ip[1] <= 31) return true;
|
|
187
|
+
// RFC 6598 (CGNAT - Tailscale-friendly)
|
|
188
|
+
if (ip[0] === 100 && ip[1] >= 64 && ip[1] <= 127) return true;
|
|
189
|
+
// Link-local
|
|
190
|
+
if (ip[0] === 169 && ip[1] === 254) return true;
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function isIpv6(hostname: string): boolean {
|
|
195
|
+
const stripped =
|
|
196
|
+
hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
|
|
197
|
+
return stripped.includes(':');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function normaliseIpv6(hostname: string): string {
|
|
201
|
+
return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isLoopbackIpv6(hostname: string): boolean {
|
|
205
|
+
const norm = normaliseIpv6(hostname).toLowerCase();
|
|
206
|
+
return norm === '::1' || norm === '0:0:0:0:0:0:0:1';
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isPrivateIpv6(hostname: string): boolean {
|
|
210
|
+
const norm = normaliseIpv6(hostname).toLowerCase();
|
|
211
|
+
// Link-local fe80::/10
|
|
212
|
+
if (
|
|
213
|
+
norm.startsWith('fe80:') ||
|
|
214
|
+
norm.startsWith('fe8') ||
|
|
215
|
+
norm.startsWith('fe9') ||
|
|
216
|
+
norm.startsWith('fea') ||
|
|
217
|
+
norm.startsWith('feb')
|
|
218
|
+
) {
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
// Unique local fc00::/7 (covers fc:: and fd::)
|
|
222
|
+
if (norm.startsWith('fc') || norm.startsWith('fd')) return true;
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function isLoopbackHostname(hostname: string): boolean {
|
|
227
|
+
const lower = hostname.toLowerCase();
|
|
228
|
+
return lower === 'localhost' || lower === 'localhost.';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function isPrivateHostnameSuffix(hostname: string): boolean {
|
|
232
|
+
const lower = hostname.toLowerCase();
|
|
233
|
+
return (
|
|
234
|
+
lower.endsWith('.local') ||
|
|
235
|
+
lower.endsWith('.lan') ||
|
|
236
|
+
lower.endsWith('.internal') ||
|
|
237
|
+
lower.endsWith('.home.arpa') ||
|
|
238
|
+
lower.endsWith('.intranet')
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Permanent loopback classification used by in-process adapters
|
|
244
|
+
* (e.g. the `llamaCppNodeAdapter` companion package). Adapters that
|
|
245
|
+
* have no `baseUrl` declare this directly to make the source-of-truth
|
|
246
|
+
* symmetry obvious.
|
|
247
|
+
*
|
|
248
|
+
* @stable
|
|
249
|
+
*/
|
|
250
|
+
export const PERMANENT_LOOPBACK_CLASSIFICATION: LocalProviderClassification = Object.freeze({
|
|
251
|
+
trust: 'loopback',
|
|
252
|
+
reason: 'in-process adapter (no baseUrl)',
|
|
253
|
+
acceptsSensitivity: SENSITIVITY_DEFAULTS_PER_TRUST.loopback,
|
|
254
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trust subsystem barrel - exports the shared classifier and the
|
|
3
|
+
* per-tier sensitivity defaults.
|
|
4
|
+
*
|
|
5
|
+
* @packageDocumentation
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type { LocalProviderTrust, OllamaTrust } from '@graphorin/core';
|
|
9
|
+
export {
|
|
10
|
+
classifyLocalProvider,
|
|
11
|
+
type LocalProviderClassification,
|
|
12
|
+
PERMANENT_LOOPBACK_CLASSIFICATION,
|
|
13
|
+
SENSITIVITY_DEFAULTS_PER_TRUST,
|
|
14
|
+
} from './classify-local-provider.js';
|
package/dist/adapters/index.js
DELETED