@graphorin/provider 0.6.1 → 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.
Files changed (74) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -1
  3. package/dist/adapters/llamacpp-server.d.ts +1 -1
  4. package/dist/adapters/llamacpp-server.js.map +1 -1
  5. package/dist/adapters/ollama.d.ts.map +1 -1
  6. package/dist/adapters/ollama.js +19 -6
  7. package/dist/adapters/ollama.js.map +1 -1
  8. package/dist/adapters/vercel.d.ts +1 -1
  9. package/dist/adapters/vercel.d.ts.map +1 -1
  10. package/dist/adapters/vercel.js +111 -33
  11. package/dist/adapters/vercel.js.map +1 -1
  12. package/dist/errors/errors.d.ts +1 -1
  13. package/dist/errors/errors.js +1 -1
  14. package/dist/errors/errors.js.map +1 -1
  15. package/dist/index.js +0 -6
  16. package/dist/index.js.map +1 -1
  17. package/dist/internal/http.js +111 -7
  18. package/dist/internal/http.js.map +1 -1
  19. package/dist/internal/openai-shaped.js +21 -4
  20. package/dist/internal/openai-shaped.js.map +1 -1
  21. package/dist/middleware/with-fallback.js +1 -1
  22. package/dist/middleware/with-rate-limit.d.ts +21 -8
  23. package/dist/middleware/with-rate-limit.d.ts.map +1 -1
  24. package/dist/middleware/with-rate-limit.js +65 -12
  25. package/dist/middleware/with-rate-limit.js.map +1 -1
  26. package/dist/middleware/with-retry.js +1 -1
  27. package/dist/package.js +1 -1
  28. package/dist/package.js.map +1 -1
  29. package/package.json +18 -16
  30. package/src/adapters/index.ts +14 -0
  31. package/src/adapters/llamacpp-server.ts +102 -0
  32. package/src/adapters/ollama.ts +382 -0
  33. package/src/adapters/openai-compatible.ts +95 -0
  34. package/src/adapters/vercel-messages.ts +308 -0
  35. package/src/adapters/vercel.ts +706 -0
  36. package/src/counters/anthropic-wire.ts +199 -0
  37. package/src/counters/anthropic.ts +114 -0
  38. package/src/counters/bedrock.ts +46 -0
  39. package/src/counters/dispatcher.ts +127 -0
  40. package/src/counters/global.ts +39 -0
  41. package/src/counters/google.ts +46 -0
  42. package/src/counters/heuristic.ts +107 -0
  43. package/src/counters/index.ts +35 -0
  44. package/src/counters/js-tiktoken.ts +135 -0
  45. package/src/counters/serialize.ts +85 -0
  46. package/src/errors/errors.ts +316 -0
  47. package/src/errors/index.ts +20 -0
  48. package/src/index.ts +42 -0
  49. package/src/internal/abort.ts +30 -0
  50. package/src/internal/http.ts +388 -0
  51. package/src/internal/openai-shaped.ts +555 -0
  52. package/src/internal/sse.ts +112 -0
  53. package/src/internal/url-utils.ts +20 -0
  54. package/src/middleware/compose.ts +213 -0
  55. package/src/middleware/index.ts +37 -0
  56. package/src/middleware/production-hook.ts +47 -0
  57. package/src/middleware/with-cost-limit.ts +131 -0
  58. package/src/middleware/with-cost-tracking.ts +216 -0
  59. package/src/middleware/with-fallback.ts +157 -0
  60. package/src/middleware/with-rate-limit.ts +306 -0
  61. package/src/middleware/with-redaction.ts +671 -0
  62. package/src/middleware/with-retry.ts +274 -0
  63. package/src/middleware/with-tracing.ts +117 -0
  64. package/src/model-tier/classify.ts +125 -0
  65. package/src/model-tier/index.ts +11 -0
  66. package/src/provider.ts +121 -0
  67. package/src/reasoning/apply-policy.ts +89 -0
  68. package/src/reasoning/classify-contract.ts +120 -0
  69. package/src/reasoning/index.ts +21 -0
  70. package/src/reasoning/retention.ts +64 -0
  71. package/src/tool-examples.ts +54 -0
  72. package/src/trust/classify-local-provider.ts +254 -0
  73. package/src/trust/index.ts +14 -0
  74. 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';
@@ -1,6 +0,0 @@
1
- import { llamaCppServerAdapter } from "./llamacpp-server.js";
2
- import { ollamaAdapter } from "./ollama.js";
3
- import { openAICompatibleAdapter } from "./openai-compatible.js";
4
- import { vercelAdapter } from "./vercel.js";
5
-
6
- export { };