@maxceem/agw 0.1.7 → 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 +47 -12
- package/dist/agw.mjs +344 -91
- 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,17 +126,44 @@ 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
|
+
```
|
|
122
144
|
|
|
123
|
-
|
|
124
|
-
|
|
145
|
+
It keeps its state in `.agw-dev/`, separate from the state an installed `agw`
|
|
146
|
+
uses. To start again from nothing:
|
|
125
147
|
|
|
126
148
|
```sh
|
|
127
|
-
pnpm
|
|
128
|
-
pnpm run cli:test # run the CLI tests
|
|
149
|
+
pnpm cli:dev:reset
|
|
129
150
|
```
|
|
130
151
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
+
```
|
|
159
|
+
|
|
160
|
+
Type-check and test:
|
|
161
|
+
|
|
162
|
+
```sh
|
|
163
|
+
pnpm run cli:check
|
|
164
|
+
pnpm run cli:test
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
This project is licensed under the [Apache License 2.0](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" });
|
|
@@ -15553,9 +15553,12 @@ var CliBrowserDetailsResponseSchema = external_exports.object({
|
|
|
15553
15553
|
googleEnabled: external_exports.boolean(),
|
|
15554
15554
|
expiresAt: external_exports.string()
|
|
15555
15555
|
});
|
|
15556
|
+
var CliHandoffContinuationSchema = external_exports.enum(["cli", "console"]);
|
|
15556
15557
|
var CliBrowserSubmitResponseSchema = external_exports.object({
|
|
15557
15558
|
state: external_exports.literal("completed"),
|
|
15558
|
-
|
|
15559
|
+
/** The whole of what the page says once it is approved. */
|
|
15560
|
+
message: external_exports.string(),
|
|
15561
|
+
continueTo: CliHandoffContinuationSchema
|
|
15559
15562
|
});
|
|
15560
15563
|
var CliBrowserRegisterResponseSchema = external_exports.object({
|
|
15561
15564
|
token: external_exports.string().nullable().optional(),
|
|
@@ -15748,12 +15751,27 @@ var CliErrorDetailsSchema = external_exports.object({
|
|
|
15748
15751
|
providerGatewayId: external_exports.string().optional(),
|
|
15749
15752
|
revoked: external_exports.boolean().optional(),
|
|
15750
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(),
|
|
15751
15761
|
id: external_exports.string().optional(),
|
|
15752
15762
|
state: external_exports.string().optional(),
|
|
15753
15763
|
expiresAt: external_exports.string().optional(),
|
|
15754
15764
|
url: external_exports.string().optional(),
|
|
15755
15765
|
/** The tail of a failed subprocess's own output, for failures it explains. */
|
|
15756
|
-
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()
|
|
15757
15775
|
});
|
|
15758
15776
|
|
|
15759
15777
|
// src/apps.ts
|
|
@@ -16220,7 +16238,10 @@ function parse3(argv) {
|
|
|
16220
16238
|
if (!match || !types[match[1]])
|
|
16221
16239
|
fail(
|
|
16222
16240
|
"unknown_flag",
|
|
16223
|
-
`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
|
|
16224
16245
|
);
|
|
16225
16246
|
const name = match[1];
|
|
16226
16247
|
const inline = match[2];
|
|
@@ -16932,6 +16953,129 @@ var StateStore = class {
|
|
|
16932
16953
|
}
|
|
16933
16954
|
};
|
|
16934
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
|
+
|
|
16935
17079
|
// src/apps.ts
|
|
16936
17080
|
var unlimited = () => ({
|
|
16937
17081
|
requests: { per_minute: null, per_day: null },
|
|
@@ -17171,20 +17315,22 @@ async function appCommand(ctx, command, args, flags) {
|
|
|
17171
17315
|
({ app, resolved, config_error: configError } = updated.data);
|
|
17172
17316
|
}
|
|
17173
17317
|
let snippet;
|
|
17174
|
-
|
|
17175
|
-
|
|
17176
|
-
|
|
17177
|
-
|
|
17178
|
-
} catch {
|
|
17179
|
-
}
|
|
17318
|
+
try {
|
|
17319
|
+
const example = await appCommand(ctx, "app snippet", [app.id], {});
|
|
17320
|
+
if ("snippet" in example) snippet = example.snippet;
|
|
17321
|
+
} catch {
|
|
17180
17322
|
}
|
|
17323
|
+
if (snippet && key)
|
|
17324
|
+
snippet = `export APP_AI_GATEWAY_KEY="$(cat ${shellQuote(key.storagePath)})"
|
|
17325
|
+
|
|
17326
|
+
${snippet}`;
|
|
17181
17327
|
return {
|
|
17182
17328
|
...snippet ? { snippet } : {},
|
|
17183
17329
|
app,
|
|
17184
17330
|
resolved,
|
|
17185
17331
|
config_error: configError,
|
|
17186
17332
|
...key ? { applicationKey: key } : {},
|
|
17187
|
-
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.`
|
|
17188
17334
|
};
|
|
17189
17335
|
} finally {
|
|
17190
17336
|
await output?.cancel();
|
|
@@ -17271,62 +17417,75 @@ async function appCommand(ctx, command, args, flags) {
|
|
|
17271
17417
|
};
|
|
17272
17418
|
}
|
|
17273
17419
|
if (action === "snippet") {
|
|
17274
|
-
|
|
17275
|
-
|
|
17276
|
-
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)
|
|
17277
17425
|
fail(
|
|
17278
17426
|
"unsupported_snippet",
|
|
17279
|
-
"Swift snippets are for iOS applications.",
|
|
17280
|
-
|
|
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"}.`
|
|
17281
17429
|
);
|
|
17282
|
-
|
|
17430
|
+
const notes = [];
|
|
17431
|
+
let example;
|
|
17283
17432
|
if (flags.endpoint) {
|
|
17284
|
-
|
|
17433
|
+
const endpoint = doc.config.endpoints?.[flags.endpoint];
|
|
17434
|
+
if (!endpoint)
|
|
17285
17435
|
fail("endpoint_not_found", "Choose an existing named endpoint.");
|
|
17286
|
-
|
|
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
|
+
};
|
|
17287
17443
|
} else {
|
|
17444
|
+
const routing = {
|
|
17445
|
+
providerMode: doc.config.routing.providers.mode,
|
|
17446
|
+
providers: doc.config.routing.providers.selected
|
|
17447
|
+
};
|
|
17288
17448
|
const { data: all } = await ctx.call("listProviders", []);
|
|
17289
|
-
|
|
17290
|
-
(
|
|
17449
|
+
const reachable = all.providers.filter(
|
|
17450
|
+
(p) => p.status === "active" && (routing.providerMode === "all" || Object.hasOwn(routing.providers ?? {}, p.slug))
|
|
17291
17451
|
);
|
|
17292
17452
|
const requested = typeof flags.provider === "string" ? flags.provider : void 0;
|
|
17293
|
-
if (requested
|
|
17294
|
-
const p = providers[0];
|
|
17295
|
-
if (providers.length !== 1 || !p)
|
|
17453
|
+
if (requested && !reachable.some((p) => p.slug === requested))
|
|
17296
17454
|
fail(
|
|
17297
|
-
"
|
|
17298
|
-
|
|
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."
|
|
17299
17458
|
);
|
|
17300
|
-
const { data:
|
|
17301
|
-
const
|
|
17302
|
-
|
|
17303
|
-
if (!
|
|
17304
|
-
|
|
17305
|
-
|
|
17306
|
-
|
|
17307
|
-
|
|
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."
|
|
17308
17474
|
);
|
|
17309
|
-
|
|
17310
|
-
|
|
17311
|
-
|
|
17312
|
-
|
|
17313
|
-
|
|
17314
|
-
|
|
17315
|
-
|
|
17316
|
-
|
|
17317
|
-
|
|
17318
|
-
|
|
17319
|
-
|
|
17320
|
-
|
|
17321
|
-
|
|
17322
|
-
|
|
17323
|
-
)
|
|
17324
|
-
|
|
17325
|
-
var request = try await gateway.authorizedRequest(
|
|
17326
|
-
${target}
|
|
17327
|
-
)
|
|
17328
|
-
// Supply the provider-native JSON body, then send request with URLSession.
|
|
17329
|
-
`;
|
|
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
|
+
}
|
|
17330
17489
|
if (flags.output) {
|
|
17331
17490
|
const output = await reserveOutput(flags.output);
|
|
17332
17491
|
try {
|
|
@@ -17336,13 +17495,7 @@ var request = try await gateway.authorizedRequest(
|
|
|
17336
17495
|
await output.cancel();
|
|
17337
17496
|
}
|
|
17338
17497
|
}
|
|
17339
|
-
return {
|
|
17340
|
-
language: "swift",
|
|
17341
|
-
snippet,
|
|
17342
|
-
...issuer ? {
|
|
17343
|
-
requires: "Replace yourIdentitySDK.currentIDToken(forceRefresh: forceRefresh) with your configured issuer integration."
|
|
17344
|
-
} : {}
|
|
17345
|
-
};
|
|
17498
|
+
return { language: ios ? "swift" : "shell", snippet, notes };
|
|
17346
17499
|
}
|
|
17347
17500
|
fail("unknown_command", "Unknown command.");
|
|
17348
17501
|
}
|
|
@@ -17882,9 +18035,11 @@ async function openBrowser(url2) {
|
|
|
17882
18035
|
|
|
17883
18036
|
// src/deployment.ts
|
|
17884
18037
|
import { spawn as spawn2 } from "node:child_process";
|
|
18038
|
+
import { stripVTControlCharacters } from "node:util";
|
|
17885
18039
|
import { createRequire as createRequire2 } from "node:module";
|
|
17886
18040
|
import { randomUUID } from "node:crypto";
|
|
17887
18041
|
import { cp, writeFile as writeFile2, unlink as unlink2 } from "node:fs/promises";
|
|
18042
|
+
import { tmpdir } from "node:os";
|
|
17888
18043
|
import { join as join3 } from "node:path";
|
|
17889
18044
|
import { setTimeout as delay3 } from "node:timers/promises";
|
|
17890
18045
|
|
|
@@ -18130,9 +18285,11 @@ async function release(requested = VERSION, base = dirname(fileURLToPath(import.
|
|
|
18130
18285
|
|
|
18131
18286
|
// src/deployment.ts
|
|
18132
18287
|
var require2 = createRequire2(import.meta.url);
|
|
18133
|
-
var OUTPUT_TAIL =
|
|
18288
|
+
var OUTPUT_TAIL = 5e3;
|
|
18134
18289
|
function wranglerFailure(args, code, output, redact = []) {
|
|
18135
|
-
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);
|
|
18136
18293
|
return new CliError(
|
|
18137
18294
|
"wrangler_failed",
|
|
18138
18295
|
`wrangler ${args[0] ?? ""} failed${code === null ? "" : ` (exit ${code})`}.`,
|
|
@@ -18141,16 +18298,21 @@ function wranglerFailure(args, code, output, redact = []) {
|
|
|
18141
18298
|
tail ? { output: tail } : void 0
|
|
18142
18299
|
);
|
|
18143
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
|
+
}
|
|
18144
18311
|
async function runWrangler(args, { cwd, input, interactive = false, redact } = {}) {
|
|
18145
18312
|
return new Promise((resolve3, reject2) => {
|
|
18146
18313
|
const child = spawn2(process.execPath, [require2.resolve("wrangler/bin/wrangler.js"), ...args], {
|
|
18147
18314
|
...cwd === void 0 ? {} : { cwd },
|
|
18148
|
-
env: {
|
|
18149
|
-
...process.env,
|
|
18150
|
-
WRANGLER_SEND_METRICS: "false",
|
|
18151
|
-
WRANGLER_LOG: "error",
|
|
18152
|
-
CI: interactive ? "" : "true"
|
|
18153
|
-
},
|
|
18315
|
+
env: wranglerEnvironment({ interactive }),
|
|
18154
18316
|
stdio: [input ? "pipe" : interactive ? "inherit" : "ignore", "pipe", "pipe"]
|
|
18155
18317
|
});
|
|
18156
18318
|
let output = "";
|
|
@@ -18181,6 +18343,24 @@ async function runWrangler(args, { cwd, input, interactive = false, redact } = {
|
|
|
18181
18343
|
});
|
|
18182
18344
|
});
|
|
18183
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
|
+
}
|
|
18184
18364
|
var Cloudflare = class {
|
|
18185
18365
|
run;
|
|
18186
18366
|
fetch;
|
|
@@ -18189,22 +18369,62 @@ var Cloudflare = class {
|
|
|
18189
18369
|
this.run = run;
|
|
18190
18370
|
this.fetch = fetchImpl;
|
|
18191
18371
|
}
|
|
18192
|
-
|
|
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;
|
|
18193
18386
|
try {
|
|
18194
|
-
|
|
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);
|
|
18195
18399
|
} catch {
|
|
18196
|
-
|
|
18197
|
-
|
|
18198
|
-
|
|
18199
|
-
|
|
18200
|
-
|
|
18201
|
-
|
|
18202
|
-
);
|
|
18203
|
-
await this.run(["login"], { interactive: true });
|
|
18204
|
-
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
|
+
);
|
|
18205
18406
|
}
|
|
18206
|
-
|
|
18207
|
-
|
|
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
|
+
);
|
|
18208
18428
|
}
|
|
18209
18429
|
authHeaders() {
|
|
18210
18430
|
const auth = this.auth;
|
|
@@ -18240,13 +18460,16 @@ var Cloudflare = class {
|
|
|
18240
18460
|
} catch {
|
|
18241
18461
|
fail("cloudflare_error", "Cloudflare returned an invalid response.");
|
|
18242
18462
|
}
|
|
18243
|
-
if (!response.ok || !data.success)
|
|
18463
|
+
if (!response.ok || !data.success) {
|
|
18464
|
+
const reported2 = apiErrors(data);
|
|
18244
18465
|
fail(
|
|
18245
18466
|
"cloudflare_error",
|
|
18246
|
-
`Cloudflare rejected the request (HTTP ${response.status})
|
|
18467
|
+
`Cloudflare rejected the request (HTTP ${response.status})${reported2 ? `: ${reported2}` : ""}`,
|
|
18247
18468
|
"Check Cloudflare permissions and the deployment resource IDs; retry the same command.",
|
|
18248
|
-
3
|
|
18469
|
+
3,
|
|
18470
|
+
reported2 ? { apiErrors: reported2 } : void 0
|
|
18249
18471
|
);
|
|
18472
|
+
}
|
|
18250
18473
|
return data;
|
|
18251
18474
|
}
|
|
18252
18475
|
async all(path) {
|
|
@@ -18830,6 +19053,27 @@ function reported(value, sent) {
|
|
|
18830
19053
|
}
|
|
18831
19054
|
var RECOVERABLE_RESOURCE_CODES = ["resource_receipt_expired", "resource_key_unavailable"];
|
|
18832
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
|
+
}
|
|
18833
19077
|
var Transport = class {
|
|
18834
19078
|
fetch;
|
|
18835
19079
|
constructor(fetchImpl = fetch) {
|
|
@@ -18884,18 +19128,27 @@ var Transport = class {
|
|
|
18884
19128
|
const wire = data ?? {};
|
|
18885
19129
|
const code = typeof wire.error?.code === "string" ? wire.error.code : typeof wire.code === "string" ? wire.code : `http_${response.status}`;
|
|
18886
19130
|
const details = { status: response.status };
|
|
18887
|
-
|
|
18888
|
-
|
|
19131
|
+
const facts = wire.error?.data ?? {};
|
|
19132
|
+
if (RECOVERABLE_RESOURCE_CODES.includes(code))
|
|
18889
19133
|
for (const field of RECOVERABLE_RESOURCE_FIELDS) {
|
|
18890
|
-
const value =
|
|
19134
|
+
const value = facts[field];
|
|
18891
19135
|
if (typeof value === "string") details[field] = value;
|
|
18892
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
|
+
}
|
|
18893
19146
|
}
|
|
18894
19147
|
const explanation = reported(wire.error?.message ?? wire.message, body);
|
|
18895
19148
|
fail(
|
|
18896
19149
|
code,
|
|
18897
19150
|
`Deployment rejected the request (HTTP ${response.status})` + (explanation ? `: ${explanation}` : "."),
|
|
18898
|
-
response.status
|
|
19151
|
+
nextActionFor(response.status, code, details),
|
|
18899
19152
|
3,
|
|
18900
19153
|
details
|
|
18901
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.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"
|