@maxceem/agw 0.1.8 → 0.1.9
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/README.md +44 -8
- package/dist/agw.mjs +340 -90
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -27,18 +27,26 @@ Add a provider. Paste its API key into the hidden prompt.
|
|
|
27
27
|
agw provider add --type openai
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
Add an app. It uses every provider you have added.
|
|
30
|
+
Add an app. It uses every provider you have added. A server app is created with
|
|
31
|
+
an application key, written to a private file; an iOS app authenticates with
|
|
32
|
+
App Attest instead and ships no key.
|
|
31
33
|
|
|
32
34
|
```sh
|
|
35
|
+
agw app add --type server --name "My backend"
|
|
33
36
|
agw app add --type ios --team-id ABCDE12345 --bundle-id com.example.app
|
|
34
37
|
```
|
|
35
38
|
|
|
36
|
-
|
|
39
|
+
Creating an app prints the request it can send. To print it again — as curl for
|
|
40
|
+
a server app, as Swift for an iOS one:
|
|
37
41
|
|
|
38
42
|
```sh
|
|
39
|
-
agw app snippet <app-id>
|
|
43
|
+
agw app snippet <app-id>
|
|
44
|
+
agw app snippet <app-id> --provider openai # when several could serve it
|
|
40
45
|
```
|
|
41
46
|
|
|
47
|
+
The example is written from the app's own providers and models, and uses named
|
|
48
|
+
placeholders such as `PROVIDER_SLUG` for anything not configured yet.
|
|
49
|
+
|
|
42
50
|
## Self-host on Cloudflare
|
|
43
51
|
|
|
44
52
|
The package drives Wrangler and installs the gateway release of its own version.
|
|
@@ -118,14 +126,42 @@ overwritten. `app validate --file` checks a file without touching the account.
|
|
|
118
126
|
Windows), mode 0700. Do not delete it to fix an authentication error; run
|
|
119
127
|
`agw account login` instead. Output files are never overwritten.
|
|
120
128
|
|
|
121
|
-
##
|
|
129
|
+
## Local development
|
|
130
|
+
|
|
131
|
+
The source lives in `cli/` of the
|
|
132
|
+
[main repository](https://github.com/maxceem/app-ai-gateway). Clone it and run
|
|
133
|
+
`pnpm install`.
|
|
134
|
+
|
|
135
|
+
From the repository root, `pnpm cli:dev` is `agw` built from your own source
|
|
136
|
+
instead of an installed copy. It rebuilds before every run, so your latest edit
|
|
137
|
+
is always the one that runs.
|
|
138
|
+
|
|
139
|
+
```sh
|
|
140
|
+
pnpm cli:dev account login
|
|
141
|
+
pnpm cli:dev provider add --type openai
|
|
142
|
+
pnpm cli:dev app list
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
It keeps its state in `.agw-dev/`, separate from the state an installed `agw`
|
|
146
|
+
uses. To start again from nothing:
|
|
147
|
+
|
|
148
|
+
```sh
|
|
149
|
+
pnpm cli:dev:reset
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Deploying needs the gateway itself built too, once per change to the Worker,
|
|
153
|
+
console or migrations:
|
|
154
|
+
|
|
155
|
+
```sh
|
|
156
|
+
pnpm --filter @maxceem/agw release:build
|
|
157
|
+
pnpm cli:dev deployment setup --name my-gateway --no-domain
|
|
158
|
+
```
|
|
122
159
|
|
|
123
|
-
|
|
124
|
-
[main repository](https://github.com/maxceem/app-ai-gateway). From the repo root:
|
|
160
|
+
Type-check and test:
|
|
125
161
|
|
|
126
162
|
```sh
|
|
127
|
-
pnpm run cli:check
|
|
128
|
-
pnpm run cli:test
|
|
163
|
+
pnpm run cli:check
|
|
164
|
+
pnpm run cli:test
|
|
129
165
|
```
|
|
130
166
|
|
|
131
167
|
## License
|
package/dist/agw.mjs
CHANGED
|
@@ -6,7 +6,7 @@ var __export = (target, all) => {
|
|
|
6
6
|
};
|
|
7
7
|
|
|
8
8
|
// <define:__AGW_MANIFEST__>
|
|
9
|
-
var define_AGW_MANIFEST_default = { version: "0.1.
|
|
9
|
+
var define_AGW_MANIFEST_default = { version: "0.1.9", wrangler: "4.96.0", upgradeFrom: ["0.1.9", "0.1.8", "0.1.7", "0.1.6", "0.1.5", "0.1.4", "0.1.3", "0.1.2", "0.1.1", "0.1.0"], release: { url: "https://github.com/maxceem/app-ai-gateway/releases/download/v0.1.9/gateway-0.1.9.tar.gz", sha256: "a3722d70b8345b41d9e57662985141006214a1b02628af6016b4763e0d177287", manifest: "40ef2a1c2a44d1530a45a3a20eb4df4eb2a840af71ee53762432430f8262c142" } };
|
|
10
10
|
|
|
11
11
|
// src/main.ts
|
|
12
12
|
import { Writable } from "node:stream";
|
|
@@ -15018,7 +15018,7 @@ var ErrorResponseSchema = external_exports.object({
|
|
|
15018
15018
|
code: external_exports.string(),
|
|
15019
15019
|
message: external_exports.string(),
|
|
15020
15020
|
data: external_exports.record(external_exports.string(), external_exports.union([external_exports.string(), external_exports.number(), external_exports.boolean()])).optional().meta({
|
|
15021
|
-
description: "Machine-readable facts about this rejection, present only where the code alone is not actionable. A billing_request_quota_exceeded rejection carries periodId, periodStart, periodEnd, limit, used, and resetAt; an app_rate_limited or app_budget_exhausted rejection carries scope, either user or app."
|
|
15021
|
+
description: "Machine-readable facts about this rejection, present only where the code alone is not actionable. A billing_request_quota_exceeded rejection carries periodId, periodStart, periodEnd, limit, used, and resetAt; a billing_plan_limit_reached rejection carries limit and used; an app_rate_limited or app_budget_exhausted rejection carries scope, either user or app; a rate_limited rejection carries scope naming the endpoint policy that refused, plus limit, windowSeconds, retryAfterSeconds and resetAt."
|
|
15022
15022
|
})
|
|
15023
15023
|
})
|
|
15024
15024
|
}).meta({ id: "ErrorResponse" });
|
|
@@ -15751,12 +15751,27 @@ var CliErrorDetailsSchema = external_exports.object({
|
|
|
15751
15751
|
providerGatewayId: external_exports.string().optional(),
|
|
15752
15752
|
revoked: external_exports.boolean().optional(),
|
|
15753
15753
|
storagePath: external_exports.string().optional(),
|
|
15754
|
+
/** Which ceiling refused the request, for the codes that name one. */
|
|
15755
|
+
scope: external_exports.string().optional(),
|
|
15756
|
+
limit: external_exports.number().optional(),
|
|
15757
|
+
used: external_exports.number().optional(),
|
|
15758
|
+
windowSeconds: external_exports.number().optional(),
|
|
15759
|
+
retryAfterSeconds: external_exports.number().optional(),
|
|
15760
|
+
resetAt: external_exports.string().optional(),
|
|
15754
15761
|
id: external_exports.string().optional(),
|
|
15755
15762
|
state: external_exports.string().optional(),
|
|
15756
15763
|
expiresAt: external_exports.string().optional(),
|
|
15757
15764
|
url: external_exports.string().optional(),
|
|
15758
15765
|
/** The tail of a failed subprocess's own output, for failures it explains. */
|
|
15759
|
-
output: external_exports.string().optional()
|
|
15766
|
+
output: external_exports.string().optional(),
|
|
15767
|
+
/**
|
|
15768
|
+
* What Cloudflare itself said about a refused API call: each error's message
|
|
15769
|
+
* and its code. Carried because the things that refuse a deployment — an
|
|
15770
|
+
* account ceiling, a permission a token lacks — are named only there, and
|
|
15771
|
+
* wrangler reports an API refusal it aggregates as the bare sentence that a
|
|
15772
|
+
* request failed.
|
|
15773
|
+
*/
|
|
15774
|
+
apiErrors: external_exports.string().optional()
|
|
15760
15775
|
});
|
|
15761
15776
|
|
|
15762
15777
|
// src/apps.ts
|
|
@@ -16223,7 +16238,10 @@ function parse3(argv) {
|
|
|
16223
16238
|
if (!match || !types[match[1]])
|
|
16224
16239
|
fail(
|
|
16225
16240
|
"unknown_flag",
|
|
16226
|
-
`Unknown flag ${token.split("=")[0]} for ${command}
|
|
16241
|
+
`Unknown flag ${token.split("=")[0]} for ${command}.`,
|
|
16242
|
+
// Resources are addressed positionally throughout, so the one mistake
|
|
16243
|
+
// worth answering with more than the help text is naming one as a flag.
|
|
16244
|
+
spec.args && ["id", "app", "app-id"].includes(match?.[1] ?? "") ? `Pass the identifier as an argument: agw ${command}${" <id>".repeat(spec.args)}.` : void 0
|
|
16227
16245
|
);
|
|
16228
16246
|
const name = match[1];
|
|
16229
16247
|
const inline = match[2];
|
|
@@ -16935,6 +16953,129 @@ var StateStore = class {
|
|
|
16935
16953
|
}
|
|
16936
16954
|
};
|
|
16937
16955
|
|
|
16956
|
+
// ../src/shared/first-request.ts
|
|
16957
|
+
var PROVIDER_PLACEHOLDER = "PROVIDER_SLUG";
|
|
16958
|
+
var MODEL_PLACEHOLDER = "MODEL";
|
|
16959
|
+
var BODY_PLACEHOLDER = "REQUEST_BODY";
|
|
16960
|
+
var EXAMPLE_GAP_NOTES = {
|
|
16961
|
+
provider: `No provider is configured yet, so ${PROVIDER_PLACEHOLDER} stands in for one. Add a provider key, then use its slug.`,
|
|
16962
|
+
model: `No priced model was found for this provider, so ${MODEL_PLACEHOLDER} stands in for one. Use a model the provider serves.`,
|
|
16963
|
+
body: `This app allows no path whose request shape is known here, so ${BODY_PLACEHOLDER} stands in for the body the provider documents.`
|
|
16964
|
+
};
|
|
16965
|
+
function examplePath(type, { gatewayRouted = false, model } = {}) {
|
|
16966
|
+
if (type === "openai") return "v1/responses";
|
|
16967
|
+
if (type === "anthropic") return "v1/messages";
|
|
16968
|
+
if (type === "gemini")
|
|
16969
|
+
return model ? `v1beta/models/${model}:generateContent` : void 0;
|
|
16970
|
+
if (gatewayRouted) return "v1/chat/completions";
|
|
16971
|
+
if (type === "groq") return "openai/v1/chat/completions";
|
|
16972
|
+
if (type === "fireworks") return "inference/v1/chat/completions";
|
|
16973
|
+
if (["deepseek", "perplexity", "bytedance"].includes(type)) return "chat/completions";
|
|
16974
|
+
return "v1/chat/completions";
|
|
16975
|
+
}
|
|
16976
|
+
function bodyFor(path, model) {
|
|
16977
|
+
const messages = [{ role: "user", content: "Say hello." }];
|
|
16978
|
+
if (path.endsWith("responses")) return { model, input: "Say hello." };
|
|
16979
|
+
if (path.endsWith("chat/completions")) return { model, messages };
|
|
16980
|
+
if (path.endsWith("messages")) return { model, max_tokens: 128, messages };
|
|
16981
|
+
if (path.endsWith(":generateContent"))
|
|
16982
|
+
return { contents: [{ parts: [{ text: "Say hello." }] }] };
|
|
16983
|
+
return null;
|
|
16984
|
+
}
|
|
16985
|
+
function modelsFor(provider, policy2, prices) {
|
|
16986
|
+
if (policy2?.allowed_models?.length) return policy2.allowed_models;
|
|
16987
|
+
return [
|
|
16988
|
+
...Object.keys(provider.pricing ?? {}),
|
|
16989
|
+
...Object.entries(prices[provider.type] ?? {}).filter(([, price]) => price.output !== void 0).map(([model]) => model)
|
|
16990
|
+
];
|
|
16991
|
+
}
|
|
16992
|
+
function firstRequest(routing, providers, prices) {
|
|
16993
|
+
let fallback;
|
|
16994
|
+
for (const provider of providers) {
|
|
16995
|
+
if (provider.status !== "active") continue;
|
|
16996
|
+
const policy2 = routing?.providers?.[provider.slug];
|
|
16997
|
+
if (routing?.providerMode === "selected" && !policy2) continue;
|
|
16998
|
+
const models = modelsFor(provider, policy2, prices);
|
|
16999
|
+
const model = models[0] ?? MODEL_PLACEHOLDER;
|
|
17000
|
+
const gaps = models.length ? [] : ["model"];
|
|
17001
|
+
const fallbackPath = examplePath(provider.type, {
|
|
17002
|
+
gatewayRouted: Boolean(provider.providerGatewayId),
|
|
17003
|
+
model
|
|
17004
|
+
});
|
|
17005
|
+
const paths = policy2?.allowed_paths?.length ? policy2.allowed_paths : fallbackPath ? [fallbackPath] : [];
|
|
17006
|
+
for (const entry of paths) {
|
|
17007
|
+
const path = typeof entry === "string" ? entry : entry.path;
|
|
17008
|
+
if (path.includes("*")) continue;
|
|
17009
|
+
const fixed = typeof entry !== "string" ? entry.fixed_model : void 0;
|
|
17010
|
+
const body = bodyFor(path, fixed ?? model);
|
|
17011
|
+
const example = {
|
|
17012
|
+
target: { provider: provider.slug, path },
|
|
17013
|
+
body,
|
|
17014
|
+
anthropic: path.endsWith("messages"),
|
|
17015
|
+
// A fixed model is the app's own, so a catalog that has none of its
|
|
17016
|
+
// own to offer is not a gap on a path that never carries one.
|
|
17017
|
+
gaps: body && fixed ? [] : gaps
|
|
17018
|
+
};
|
|
17019
|
+
if (body) return example;
|
|
17020
|
+
fallback ??= { ...example, gaps: [...example.gaps, "body"] };
|
|
17021
|
+
}
|
|
17022
|
+
}
|
|
17023
|
+
return fallback ?? {
|
|
17024
|
+
target: { provider: PROVIDER_PLACEHOLDER, path: "v1/chat/completions" },
|
|
17025
|
+
body: bodyFor("v1/chat/completions", MODEL_PLACEHOLDER),
|
|
17026
|
+
anthropic: false,
|
|
17027
|
+
gaps: ["provider", "model"]
|
|
17028
|
+
};
|
|
17029
|
+
}
|
|
17030
|
+
function exampleNotes(example) {
|
|
17031
|
+
return example.gaps.map((gap) => EXAMPLE_GAP_NOTES[gap]);
|
|
17032
|
+
}
|
|
17033
|
+
function exampleUrl(baseUrl, appId, target) {
|
|
17034
|
+
const origin2 = baseUrl.replace(/\/+$/, "");
|
|
17035
|
+
const app = `${origin2}/v1/apps/${encodeURIComponent(appId)}`;
|
|
17036
|
+
return "endpoint" in target ? `${app}/endpoints/${encodeURIComponent(target.endpoint)}` : `${app}/proxy/${encodeURIComponent(target.provider)}/${target.path}`;
|
|
17037
|
+
}
|
|
17038
|
+
function shellQuote(value) {
|
|
17039
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
17040
|
+
}
|
|
17041
|
+
function commented(notes, marker) {
|
|
17042
|
+
return (notes ?? []).map((note) => `${marker} ${note}
|
|
17043
|
+
`).join("");
|
|
17044
|
+
}
|
|
17045
|
+
function curlSnippet(options) {
|
|
17046
|
+
const { baseUrl, appId, example, notes, keyExpression = "$APP_AI_GATEWAY_KEY" } = options;
|
|
17047
|
+
const body = example.body ? JSON.stringify(example.body) : BODY_PLACEHOLDER;
|
|
17048
|
+
return commented(notes, "#") + `curl --fail-with-body ${shellQuote(exampleUrl(baseUrl, appId, example.target))} \\
|
|
17049
|
+
-H "Authorization: Bearer ${keyExpression}" \\
|
|
17050
|
+
-H 'Content-Type: application/json' \\
|
|
17051
|
+
` + (example.anthropic ? " -H 'anthropic-version: 2023-06-01' \\\n" : "") + ` -d ${shellQuote(body)}
|
|
17052
|
+
`;
|
|
17053
|
+
}
|
|
17054
|
+
function swiftSnippet(options) {
|
|
17055
|
+
const { baseUrl, appId, example, notes, authMode = ".appAttestInstall" } = options;
|
|
17056
|
+
const target = "endpoint" in example.target ? `endpointSlug: ${JSON.stringify(example.target.endpoint)}` : `provider: ${JSON.stringify(example.target.provider)},
|
|
17057
|
+
providerPath: ${JSON.stringify(example.target.path)}`;
|
|
17058
|
+
const body = example.body ? `request.httpBody = Data(${JSON.stringify(JSON.stringify(example.body))}.utf8)` : `// Supply the provider-native JSON body.
|
|
17059
|
+
request.httpBody = Data(${JSON.stringify(BODY_PLACEHOLDER)}.utf8)`;
|
|
17060
|
+
return commented(notes, "//") + `import Foundation
|
|
17061
|
+
import AppAIGateway
|
|
17062
|
+
|
|
17063
|
+
let gateway = AppAIGatewayClient(
|
|
17064
|
+
appID: ${JSON.stringify(appId)},
|
|
17065
|
+
baseURL: URL(string: ${JSON.stringify(baseUrl.replace(/\/+$/, ""))})!,
|
|
17066
|
+
authMode: ${authMode}
|
|
17067
|
+
)
|
|
17068
|
+
|
|
17069
|
+
var request = try await gateway.authorizedRequest(
|
|
17070
|
+
${target}
|
|
17071
|
+
)
|
|
17072
|
+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
17073
|
+
${example.anthropic ? 'request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version")\n' : ""}${body}
|
|
17074
|
+
let (data, _) = try await URLSession.shared.data(for: request)
|
|
17075
|
+
print(String(decoding: data, as: UTF8.self))
|
|
17076
|
+
`;
|
|
17077
|
+
}
|
|
17078
|
+
|
|
16938
17079
|
// src/apps.ts
|
|
16939
17080
|
var unlimited = () => ({
|
|
16940
17081
|
requests: { per_minute: null, per_day: null },
|
|
@@ -17174,20 +17315,22 @@ async function appCommand(ctx, command, args, flags) {
|
|
|
17174
17315
|
({ app, resolved, config_error: configError } = updated.data);
|
|
17175
17316
|
}
|
|
17176
17317
|
let snippet;
|
|
17177
|
-
|
|
17178
|
-
|
|
17179
|
-
|
|
17180
|
-
|
|
17181
|
-
} catch {
|
|
17182
|
-
}
|
|
17318
|
+
try {
|
|
17319
|
+
const example = await appCommand(ctx, "app snippet", [app.id], {});
|
|
17320
|
+
if ("snippet" in example) snippet = example.snippet;
|
|
17321
|
+
} catch {
|
|
17183
17322
|
}
|
|
17323
|
+
if (snippet && key)
|
|
17324
|
+
snippet = `export APP_AI_GATEWAY_KEY="$(cat ${shellQuote(key.storagePath)})"
|
|
17325
|
+
|
|
17326
|
+
${snippet}`;
|
|
17184
17327
|
return {
|
|
17185
17328
|
...snippet ? { snippet } : {},
|
|
17186
17329
|
app,
|
|
17187
17330
|
resolved,
|
|
17188
17331
|
config_error: configError,
|
|
17189
17332
|
...key ? { applicationKey: key } : {},
|
|
17190
|
-
guidance: doc2.config.authentication.type === "apple_app_attest" ? `Add https://github.com/maxceem/app-ai-gateway-swift from 1.0.0, enable App Attest, and test on a supported physical device. Run agw app snippet ${app.id} --provider <slug> for integration code. Production releases should use production-only App Attest.` :
|
|
17333
|
+
guidance: doc2.config.authentication.type === "apple_app_attest" ? `Add https://github.com/maxceem/app-ai-gateway-swift from 1.0.0, enable App Attest, and test on a supported physical device. Run agw app snippet ${app.id} --provider <slug> for integration code. Production releases should use production-only App Attest.` : `Store the generated key on your server; never embed it in a mobile application. Run agw app snippet ${app.id} for this request again.`
|
|
17191
17334
|
};
|
|
17192
17335
|
} finally {
|
|
17193
17336
|
await output?.cancel();
|
|
@@ -17274,62 +17417,75 @@ async function appCommand(ctx, command, args, flags) {
|
|
|
17274
17417
|
};
|
|
17275
17418
|
}
|
|
17276
17419
|
if (action === "snippet") {
|
|
17277
|
-
|
|
17278
|
-
|
|
17279
|
-
if (
|
|
17420
|
+
const ios = doc.config.authentication.type === "apple_app_attest";
|
|
17421
|
+
const language = flags.language ?? (ios ? "swift" : "curl");
|
|
17422
|
+
if (!["swift", "curl"].includes(language))
|
|
17423
|
+
fail("invalid_input", "--language must be swift or curl.");
|
|
17424
|
+
if (language === "swift" !== ios)
|
|
17280
17425
|
fail(
|
|
17281
17426
|
"unsupported_snippet",
|
|
17282
|
-
"Swift snippets are for iOS applications.",
|
|
17283
|
-
|
|
17427
|
+
ios ? "iOS applications authenticate with App Attest, which curl cannot perform." : "Swift snippets are for iOS applications.",
|
|
17428
|
+
`Run agw app snippet ${appId} --language ${ios ? "swift" : "curl"}.`
|
|
17284
17429
|
);
|
|
17285
|
-
|
|
17430
|
+
const notes = [];
|
|
17431
|
+
let example;
|
|
17286
17432
|
if (flags.endpoint) {
|
|
17287
|
-
|
|
17433
|
+
const endpoint = doc.config.endpoints?.[flags.endpoint];
|
|
17434
|
+
if (!endpoint)
|
|
17288
17435
|
fail("endpoint_not_found", "Choose an existing named endpoint.");
|
|
17289
|
-
|
|
17436
|
+
const responses = endpoint.api_style === "responses";
|
|
17437
|
+
example = {
|
|
17438
|
+
target: { endpoint: flags.endpoint },
|
|
17439
|
+
body: responses ? { input: "Say hello." } : null,
|
|
17440
|
+
anthropic: false,
|
|
17441
|
+
gaps: responses ? [] : ["body"]
|
|
17442
|
+
};
|
|
17290
17443
|
} else {
|
|
17444
|
+
const routing = {
|
|
17445
|
+
providerMode: doc.config.routing.providers.mode,
|
|
17446
|
+
providers: doc.config.routing.providers.selected
|
|
17447
|
+
};
|
|
17291
17448
|
const { data: all } = await ctx.call("listProviders", []);
|
|
17292
|
-
|
|
17293
|
-
(
|
|
17449
|
+
const reachable = all.providers.filter(
|
|
17450
|
+
(p) => p.status === "active" && (routing.providerMode === "all" || Object.hasOwn(routing.providers ?? {}, p.slug))
|
|
17294
17451
|
);
|
|
17295
17452
|
const requested = typeof flags.provider === "string" ? flags.provider : void 0;
|
|
17296
|
-
if (requested
|
|
17297
|
-
const p = providers[0];
|
|
17298
|
-
if (providers.length !== 1 || !p)
|
|
17453
|
+
if (requested && !reachable.some((p) => p.slug === requested))
|
|
17299
17454
|
fail(
|
|
17300
|
-
"
|
|
17301
|
-
|
|
17455
|
+
all.providers.some((p) => p.slug === requested) ? "provider_unavailable" : "provider_not_found",
|
|
17456
|
+
all.providers.some((p) => p.slug === requested) ? "That provider is disabled or outside this app's proxy policy." : "No provider has that slug.",
|
|
17457
|
+
"Run agw provider list for the slugs, and agw app show <id> for the policy."
|
|
17302
17458
|
);
|
|
17303
|
-
const { data:
|
|
17304
|
-
const
|
|
17305
|
-
|
|
17306
|
-
if (!
|
|
17307
|
-
|
|
17308
|
-
|
|
17309
|
-
|
|
17310
|
-
|
|
17459
|
+
const { data: catalog } = await ctx.call("listModelPrices", []);
|
|
17460
|
+
const providers = requested ? reachable.filter((p) => p.slug === requested) : reachable;
|
|
17461
|
+
example = firstRequest(routing, providers, catalog.prices);
|
|
17462
|
+
if (!requested && reachable.length > 1)
|
|
17463
|
+
notes.push(`This app can reach ${reachable.length} providers. Add --provider <slug> for a different one.`);
|
|
17464
|
+
}
|
|
17465
|
+
notes.push(...exampleNotes(example));
|
|
17466
|
+
const clientUrl = ctx.active?.deployment?.apiUrl ?? ctx.url;
|
|
17467
|
+
let snippet;
|
|
17468
|
+
if (ios) {
|
|
17469
|
+
const endUser = doc.config.authentication.type === "apple_app_attest" ? doc.config.authentication.end_user : void 0;
|
|
17470
|
+
const issuer = endUser?.source === "issuer";
|
|
17471
|
+
if (issuer)
|
|
17472
|
+
notes.push(
|
|
17473
|
+
"Replace yourIdentitySDK.currentIDToken(forceRefresh: forceRefresh) with your configured issuer integration."
|
|
17311
17474
|
);
|
|
17312
|
-
|
|
17313
|
-
|
|
17314
|
-
|
|
17315
|
-
|
|
17316
|
-
|
|
17317
|
-
|
|
17318
|
-
|
|
17319
|
-
|
|
17320
|
-
|
|
17321
|
-
|
|
17322
|
-
|
|
17323
|
-
|
|
17324
|
-
|
|
17325
|
-
|
|
17326
|
-
)
|
|
17327
|
-
|
|
17328
|
-
var request = try await gateway.authorizedRequest(
|
|
17329
|
-
${target}
|
|
17330
|
-
)
|
|
17331
|
-
// Supply the provider-native JSON body, then send request with URLSession.
|
|
17332
|
-
`;
|
|
17475
|
+
snippet = swiftSnippet({
|
|
17476
|
+
baseUrl: clientUrl,
|
|
17477
|
+
appId,
|
|
17478
|
+
example,
|
|
17479
|
+
authMode: issuer ? ".appAttest(issuerTokenProvider: { forceRefresh in\n // Return a fresh signed token from your configured identity SDK.\n try await yourIdentitySDK.currentIDToken(forceRefresh: forceRefresh)\n })" : ".appAttestInstall",
|
|
17480
|
+
notes: [
|
|
17481
|
+
"Swift package: https://github.com/maxceem/app-ai-gateway-swift (from: 1.0.0)",
|
|
17482
|
+
"Enable App Attest and test on a supported physical device.",
|
|
17483
|
+
...notes
|
|
17484
|
+
]
|
|
17485
|
+
});
|
|
17486
|
+
} else {
|
|
17487
|
+
snippet = curlSnippet({ baseUrl: clientUrl, appId, example, notes });
|
|
17488
|
+
}
|
|
17333
17489
|
if (flags.output) {
|
|
17334
17490
|
const output = await reserveOutput(flags.output);
|
|
17335
17491
|
try {
|
|
@@ -17339,13 +17495,7 @@ var request = try await gateway.authorizedRequest(
|
|
|
17339
17495
|
await output.cancel();
|
|
17340
17496
|
}
|
|
17341
17497
|
}
|
|
17342
|
-
return {
|
|
17343
|
-
language: "swift",
|
|
17344
|
-
snippet,
|
|
17345
|
-
...issuer ? {
|
|
17346
|
-
requires: "Replace yourIdentitySDK.currentIDToken(forceRefresh: forceRefresh) with your configured issuer integration."
|
|
17347
|
-
} : {}
|
|
17348
|
-
};
|
|
17498
|
+
return { language: ios ? "swift" : "shell", snippet, notes };
|
|
17349
17499
|
}
|
|
17350
17500
|
fail("unknown_command", "Unknown command.");
|
|
17351
17501
|
}
|
|
@@ -17885,9 +18035,11 @@ async function openBrowser(url2) {
|
|
|
17885
18035
|
|
|
17886
18036
|
// src/deployment.ts
|
|
17887
18037
|
import { spawn as spawn2 } from "node:child_process";
|
|
18038
|
+
import { stripVTControlCharacters } from "node:util";
|
|
17888
18039
|
import { createRequire as createRequire2 } from "node:module";
|
|
17889
18040
|
import { randomUUID } from "node:crypto";
|
|
17890
18041
|
import { cp, writeFile as writeFile2, unlink as unlink2 } from "node:fs/promises";
|
|
18042
|
+
import { tmpdir } from "node:os";
|
|
17891
18043
|
import { join as join3 } from "node:path";
|
|
17892
18044
|
import { setTimeout as delay3 } from "node:timers/promises";
|
|
17893
18045
|
|
|
@@ -18133,9 +18285,11 @@ async function release(requested = VERSION, base = dirname(fileURLToPath(import.
|
|
|
18133
18285
|
|
|
18134
18286
|
// src/deployment.ts
|
|
18135
18287
|
var require2 = createRequire2(import.meta.url);
|
|
18136
|
-
var OUTPUT_TAIL =
|
|
18288
|
+
var OUTPUT_TAIL = 5e3;
|
|
18137
18289
|
function wranglerFailure(args, code, output, redact = []) {
|
|
18138
|
-
const tail =
|
|
18290
|
+
const tail = stripVTControlCharacters(
|
|
18291
|
+
redact.filter((value) => value.length >= 8).reduce((text2, value) => text2.split(value).join("[redacted]"), output)
|
|
18292
|
+
).trim().slice(-OUTPUT_TAIL);
|
|
18139
18293
|
return new CliError(
|
|
18140
18294
|
"wrangler_failed",
|
|
18141
18295
|
`wrangler ${args[0] ?? ""} failed${code === null ? "" : ` (exit ${code})`}.`,
|
|
@@ -18144,16 +18298,21 @@ function wranglerFailure(args, code, output, redact = []) {
|
|
|
18144
18298
|
tail ? { output: tail } : void 0
|
|
18145
18299
|
);
|
|
18146
18300
|
}
|
|
18301
|
+
function wranglerEnvironment({
|
|
18302
|
+
interactive = false
|
|
18303
|
+
} = {}) {
|
|
18304
|
+
return {
|
|
18305
|
+
...process.env,
|
|
18306
|
+
WRANGLER_SEND_METRICS: "false",
|
|
18307
|
+
WRANGLER_LOG: "log",
|
|
18308
|
+
CI: interactive ? "" : "true"
|
|
18309
|
+
};
|
|
18310
|
+
}
|
|
18147
18311
|
async function runWrangler(args, { cwd, input, interactive = false, redact } = {}) {
|
|
18148
18312
|
return new Promise((resolve3, reject2) => {
|
|
18149
18313
|
const child = spawn2(process.execPath, [require2.resolve("wrangler/bin/wrangler.js"), ...args], {
|
|
18150
18314
|
...cwd === void 0 ? {} : { cwd },
|
|
18151
|
-
env: {
|
|
18152
|
-
...process.env,
|
|
18153
|
-
WRANGLER_SEND_METRICS: "false",
|
|
18154
|
-
WRANGLER_LOG: "error",
|
|
18155
|
-
CI: interactive ? "" : "true"
|
|
18156
|
-
},
|
|
18315
|
+
env: wranglerEnvironment({ interactive }),
|
|
18157
18316
|
stdio: [input ? "pipe" : interactive ? "inherit" : "ignore", "pipe", "pipe"]
|
|
18158
18317
|
});
|
|
18159
18318
|
let output = "";
|
|
@@ -18184,6 +18343,24 @@ async function runWrangler(args, { cwd, input, interactive = false, redact } = {
|
|
|
18184
18343
|
});
|
|
18185
18344
|
});
|
|
18186
18345
|
}
|
|
18346
|
+
function usable(auth) {
|
|
18347
|
+
return auth.type === "api_key" ? Boolean(auth.key && auth.email) : Boolean(auth.token);
|
|
18348
|
+
}
|
|
18349
|
+
function environmentCredentials() {
|
|
18350
|
+
const env = process.env;
|
|
18351
|
+
const token = env.CLOUDFLARE_API_TOKEN ?? env.CF_API_TOKEN;
|
|
18352
|
+
const key = env.CLOUDFLARE_API_KEY ?? env.CF_API_KEY;
|
|
18353
|
+
const email3 = env.CLOUDFLARE_EMAIL ?? env.CF_EMAIL;
|
|
18354
|
+
return [token, key && email3 ? key : void 0].filter(
|
|
18355
|
+
(value) => Boolean(value)
|
|
18356
|
+
);
|
|
18357
|
+
}
|
|
18358
|
+
var API_ERROR_TAIL = 500;
|
|
18359
|
+
function apiErrors(data) {
|
|
18360
|
+
return (data.errors ?? []).map(
|
|
18361
|
+
(error51) => [error51.message, error51.code === void 0 ? "" : `[code: ${error51.code}]`].filter(Boolean).join(" ")
|
|
18362
|
+
).filter(Boolean).join("; ").slice(0, API_ERROR_TAIL);
|
|
18363
|
+
}
|
|
18187
18364
|
var Cloudflare = class {
|
|
18188
18365
|
run;
|
|
18189
18366
|
fetch;
|
|
@@ -18192,22 +18369,62 @@ var Cloudflare = class {
|
|
|
18192
18369
|
this.run = run;
|
|
18193
18370
|
this.fetch = fetchImpl;
|
|
18194
18371
|
}
|
|
18195
|
-
|
|
18372
|
+
/**
|
|
18373
|
+
* The authorization wrangler holds, or undefined when it holds none.
|
|
18374
|
+
*
|
|
18375
|
+
* Only one outcome may be read as "logged out": wrangler exiting non-zero
|
|
18376
|
+
* with nothing in the environment for it to have used. A run that failed
|
|
18377
|
+
* while credentials were set failed at something else, a run that could not
|
|
18378
|
+
* start failed at nothing to do with authorization, and output that will not
|
|
18379
|
+
* parse means this CLI and the wrangler it drives disagree about the
|
|
18380
|
+
* command. Answering any of those with a login would hide the cause behind a
|
|
18381
|
+
* message about something the caller had already done.
|
|
18382
|
+
*/
|
|
18383
|
+
async readAuth() {
|
|
18384
|
+
const credentials = environmentCredentials();
|
|
18385
|
+
let reported2;
|
|
18196
18386
|
try {
|
|
18197
|
-
|
|
18387
|
+
reported2 = await this.run(["auth", "token", "--json"], {
|
|
18388
|
+
cwd: tmpdir(),
|
|
18389
|
+
redact: credentials
|
|
18390
|
+
});
|
|
18391
|
+
} catch (error51) {
|
|
18392
|
+
if (credentials.length > 0 || !(error51 instanceof CliError) || error51.code !== "wrangler_failed")
|
|
18393
|
+
throw error51;
|
|
18394
|
+
return void 0;
|
|
18395
|
+
}
|
|
18396
|
+
let auth;
|
|
18397
|
+
try {
|
|
18398
|
+
auth = JSON.parse(reported2);
|
|
18198
18399
|
} catch {
|
|
18199
|
-
|
|
18200
|
-
|
|
18201
|
-
|
|
18202
|
-
|
|
18203
|
-
|
|
18204
|
-
|
|
18205
|
-
);
|
|
18206
|
-
await this.run(["login"], { interactive: true });
|
|
18207
|
-
this.auth = JSON.parse(await this.run(["auth", "token", "--json"]));
|
|
18400
|
+
fail(
|
|
18401
|
+
"cloudflare_auth_unreadable",
|
|
18402
|
+
"Wrangler did not report the Cloudflare authorization it holds.",
|
|
18403
|
+
"Reinstall the CLI so the wrangler it bundles is the one it expects, then retry.",
|
|
18404
|
+
3
|
|
18405
|
+
);
|
|
18208
18406
|
}
|
|
18209
|
-
|
|
18210
|
-
|
|
18407
|
+
return usable(auth) ? auth : void 0;
|
|
18408
|
+
}
|
|
18409
|
+
async authenticate(flags) {
|
|
18410
|
+
this.auth = await this.readAuth();
|
|
18411
|
+
if (this.auth) return;
|
|
18412
|
+
if (flags["no-input"] || flags["dry-run"])
|
|
18413
|
+
fail(
|
|
18414
|
+
"cloudflare_auth_required",
|
|
18415
|
+
"Cloudflare authentication is required.",
|
|
18416
|
+
"Set CLOUDFLARE_API_TOKEN securely, or run setup interactively to authorize Cloudflare.",
|
|
18417
|
+
4
|
|
18418
|
+
);
|
|
18419
|
+
await this.run(["login"], { interactive: true, cwd: tmpdir() });
|
|
18420
|
+
this.auth = await this.readAuth();
|
|
18421
|
+
if (!this.auth)
|
|
18422
|
+
fail(
|
|
18423
|
+
"cloudflare_auth_required",
|
|
18424
|
+
"Cloudflare authorization was not completed.",
|
|
18425
|
+
"Read the wrangler output above, complete the browser login, then retry the same command.",
|
|
18426
|
+
4
|
|
18427
|
+
);
|
|
18211
18428
|
}
|
|
18212
18429
|
authHeaders() {
|
|
18213
18430
|
const auth = this.auth;
|
|
@@ -18243,13 +18460,16 @@ var Cloudflare = class {
|
|
|
18243
18460
|
} catch {
|
|
18244
18461
|
fail("cloudflare_error", "Cloudflare returned an invalid response.");
|
|
18245
18462
|
}
|
|
18246
|
-
if (!response.ok || !data.success)
|
|
18463
|
+
if (!response.ok || !data.success) {
|
|
18464
|
+
const reported2 = apiErrors(data);
|
|
18247
18465
|
fail(
|
|
18248
18466
|
"cloudflare_error",
|
|
18249
|
-
`Cloudflare rejected the request (HTTP ${response.status})
|
|
18467
|
+
`Cloudflare rejected the request (HTTP ${response.status})${reported2 ? `: ${reported2}` : ""}`,
|
|
18250
18468
|
"Check Cloudflare permissions and the deployment resource IDs; retry the same command.",
|
|
18251
|
-
3
|
|
18469
|
+
3,
|
|
18470
|
+
reported2 ? { apiErrors: reported2 } : void 0
|
|
18252
18471
|
);
|
|
18472
|
+
}
|
|
18253
18473
|
return data;
|
|
18254
18474
|
}
|
|
18255
18475
|
async all(path) {
|
|
@@ -18833,6 +19053,27 @@ function reported(value, sent) {
|
|
|
18833
19053
|
}
|
|
18834
19054
|
var RECOVERABLE_RESOURCE_CODES = ["resource_receipt_expired", "resource_key_unavailable"];
|
|
18835
19055
|
var RECOVERABLE_RESOURCE_FIELDS = ["appId", "keyId", "providerId", "providerGatewayId"];
|
|
19056
|
+
var LIMIT_CODES = [
|
|
19057
|
+
"rate_limited",
|
|
19058
|
+
"app_rate_limited",
|
|
19059
|
+
"app_budget_exhausted",
|
|
19060
|
+
"billing_request_quota_exceeded",
|
|
19061
|
+
"billing_plan_limit_reached"
|
|
19062
|
+
];
|
|
19063
|
+
var LIMIT_TEXT_FIELDS = ["scope", "resetAt"];
|
|
19064
|
+
var LIMIT_NUMBER_FIELDS = ["limit", "used", "windowSeconds", "retryAfterSeconds"];
|
|
19065
|
+
function nextActionFor(status, code, details) {
|
|
19066
|
+
if (status === 401) return "Run agw account login.";
|
|
19067
|
+
if (code === "billing_plan_limit_reached")
|
|
19068
|
+
return "Delete one you no longer need, or move to a plan whose limits are higher.";
|
|
19069
|
+
if (status === 429) {
|
|
19070
|
+
const seconds = details.retryAfterSeconds;
|
|
19071
|
+
if (seconds === void 0)
|
|
19072
|
+
return "Wait for the limit named in the message to reset, then run the same command again.";
|
|
19073
|
+
return seconds >= 3600 && details.resetAt !== void 0 ? `Wait until ${details.resetAt}, then run the same command again.` : `Wait ${seconds} seconds, then run the same command again.`;
|
|
19074
|
+
}
|
|
19075
|
+
return "Review command configuration and account status.";
|
|
19076
|
+
}
|
|
18836
19077
|
var Transport = class {
|
|
18837
19078
|
fetch;
|
|
18838
19079
|
constructor(fetchImpl = fetch) {
|
|
@@ -18887,18 +19128,27 @@ var Transport = class {
|
|
|
18887
19128
|
const wire = data ?? {};
|
|
18888
19129
|
const code = typeof wire.error?.code === "string" ? wire.error.code : typeof wire.code === "string" ? wire.code : `http_${response.status}`;
|
|
18889
19130
|
const details = { status: response.status };
|
|
18890
|
-
|
|
18891
|
-
|
|
19131
|
+
const facts = wire.error?.data ?? {};
|
|
19132
|
+
if (RECOVERABLE_RESOURCE_CODES.includes(code))
|
|
18892
19133
|
for (const field of RECOVERABLE_RESOURCE_FIELDS) {
|
|
18893
|
-
const value =
|
|
19134
|
+
const value = facts[field];
|
|
18894
19135
|
if (typeof value === "string") details[field] = value;
|
|
18895
19136
|
}
|
|
19137
|
+
if (LIMIT_CODES.includes(code)) {
|
|
19138
|
+
for (const field of LIMIT_TEXT_FIELDS) {
|
|
19139
|
+
const value = facts[field];
|
|
19140
|
+
if (typeof value === "string") details[field] = value;
|
|
19141
|
+
}
|
|
19142
|
+
for (const field of LIMIT_NUMBER_FIELDS) {
|
|
19143
|
+
const value = facts[field];
|
|
19144
|
+
if (typeof value === "number") details[field] = value;
|
|
19145
|
+
}
|
|
18896
19146
|
}
|
|
18897
19147
|
const explanation = reported(wire.error?.message ?? wire.message, body);
|
|
18898
19148
|
fail(
|
|
18899
19149
|
code,
|
|
18900
19150
|
`Deployment rejected the request (HTTP ${response.status})` + (explanation ? `: ${explanation}` : "."),
|
|
18901
|
-
response.status
|
|
19151
|
+
nextActionFor(response.status, code, details),
|
|
18902
19152
|
3,
|
|
18903
19153
|
details
|
|
18904
19154
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maxceem/agw",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Manage App AI Gateway from a terminal or coding agent: providers, iOS and server apps, usage, and self-hosted deployment on Cloudflare",
|
|
6
6
|
"keywords": [
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"directory": "cli"
|
|
26
26
|
},
|
|
27
27
|
"bugs": "https://github.com/maxceem/app-ai-gateway/issues",
|
|
28
|
-
"upgradeFrom": ["0.1.7", "0.1.6", "0.1.5", "0.1.4", "0.1.3", "0.1.2", "0.1.1", "0.1.0"],
|
|
28
|
+
"upgradeFrom": ["0.1.8", "0.1.7", "0.1.6", "0.1.5", "0.1.4", "0.1.3", "0.1.2", "0.1.1", "0.1.0"],
|
|
29
29
|
"license": "Apache-2.0",
|
|
30
30
|
"bin": {
|
|
31
31
|
"agw": "dist/agw.mjs"
|