@marcusrbrown/infra 0.12.3 → 0.13.1
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/package.json
CHANGED
|
@@ -28,6 +28,7 @@ Commands:
|
|
|
28
28
|
|
|
29
29
|
--url [url] Base URL for CLIProxyAPI health checks. Falls back to CLIPROXY_URL or https://cliproxy.fro.bot.
|
|
30
30
|
--key [key] Management API key. Falls back to CLIPROXY_MANAGEMENT_KEY when omitted.
|
|
31
|
+
--api-key [key] Downstream API key (bearer) for the upstream provider-auth probe. Falls back to CLIPROXY_API_KEY. Skipped when absent.
|
|
31
32
|
--verbose Enable verbose output for all commands
|
|
32
33
|
|
|
33
34
|
|
|
@@ -3,6 +3,7 @@ import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
|
|
|
3
3
|
import {createCapturedCtx, expectCapturedToInclude, MockProcessExit} from '../../__test__/mcp-ctx-fixture'
|
|
4
4
|
import {
|
|
5
5
|
checkHttpReachability,
|
|
6
|
+
checkProviderAuth,
|
|
6
7
|
checkUsageStats,
|
|
7
8
|
checkVersion,
|
|
8
9
|
cliproxyStatusAction,
|
|
@@ -839,3 +840,318 @@ describe('cliproxyStatusAction — ambient key does not follow agent-supplied UR
|
|
|
839
840
|
expect(output).toMatch(/CLIPROXY_MANAGEMENT_KEY|no key|skipping/i)
|
|
840
841
|
})
|
|
841
842
|
})
|
|
843
|
+
|
|
844
|
+
// ─── checkProviderAuth ────────────────────────────────────────────────────────
|
|
845
|
+
|
|
846
|
+
describe('checkProviderAuth', () => {
|
|
847
|
+
afterEach(() => {
|
|
848
|
+
globalThis.fetch = originalFetch
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
it('returns ok when POST /v1/chat/completions returns 200', async () => {
|
|
852
|
+
globalThis.fetch = createFetchImplementation(
|
|
853
|
+
async () =>
|
|
854
|
+
new Response(JSON.stringify({choices: [{message: {content: 'pong'}}]}), {
|
|
855
|
+
status: 200,
|
|
856
|
+
headers: {'content-type': 'application/json'},
|
|
857
|
+
}),
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
|
|
861
|
+
|
|
862
|
+
expect(result.level).toBe('ok')
|
|
863
|
+
expect(result.title).toBe('Upstream provider auth (anthropic)')
|
|
864
|
+
expect(result.summary).toContain('anthropic route OK')
|
|
865
|
+
expect(result.summary).toContain('claude-sonnet-4-6')
|
|
866
|
+
})
|
|
867
|
+
|
|
868
|
+
it('returns error when POST returns 401', async () => {
|
|
869
|
+
globalThis.fetch = createFetchImplementation(async () => new Response('Unauthorized', {status: 401}))
|
|
870
|
+
|
|
871
|
+
const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
|
|
872
|
+
|
|
873
|
+
expect(result.level).toBe('error')
|
|
874
|
+
expect(result.summary).toContain('401')
|
|
875
|
+
expect(result.summary).toContain('cliproxy login claude')
|
|
876
|
+
})
|
|
877
|
+
|
|
878
|
+
it('returns error when POST returns 503 with auth_unavailable body', async () => {
|
|
879
|
+
globalThis.fetch = createFetchImplementation(
|
|
880
|
+
async () => new Response('auth_unavailable: no auth available (providers=claude)', {status: 503}),
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
|
|
884
|
+
|
|
885
|
+
expect(result.level).toBe('error')
|
|
886
|
+
expect(result.summary).toContain('503')
|
|
887
|
+
expect(result.summary).toContain('cliproxy login claude')
|
|
888
|
+
})
|
|
889
|
+
|
|
890
|
+
it('returns error when POST returns 503 with "no auth available" in body', async () => {
|
|
891
|
+
globalThis.fetch = createFetchImplementation(async () => new Response('no auth available', {status: 503}))
|
|
892
|
+
|
|
893
|
+
const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
|
|
894
|
+
|
|
895
|
+
expect(result.level).toBe('error')
|
|
896
|
+
expect(result.summary).toContain('cliproxy login claude')
|
|
897
|
+
})
|
|
898
|
+
|
|
899
|
+
it('returns error when POST returns 503 with "providers=claude" in body', async () => {
|
|
900
|
+
globalThis.fetch = createFetchImplementation(
|
|
901
|
+
async () => new Response('{"error":"providers=claude unavailable"}', {status: 503}),
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
|
|
905
|
+
|
|
906
|
+
expect(result.level).toBe('error')
|
|
907
|
+
expect(result.summary).toContain('cliproxy login claude')
|
|
908
|
+
})
|
|
909
|
+
|
|
910
|
+
it('returns warning when POST returns 503 without auth-unavailable markers', async () => {
|
|
911
|
+
globalThis.fetch = createFetchImplementation(async () => new Response('Service Unavailable', {status: 503}))
|
|
912
|
+
|
|
913
|
+
const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
|
|
914
|
+
|
|
915
|
+
expect(result.level).toBe('warning')
|
|
916
|
+
expect(result.summary).toContain('503')
|
|
917
|
+
expect(result.summary).not.toContain('cliproxy login claude')
|
|
918
|
+
})
|
|
919
|
+
|
|
920
|
+
it('returns warning when POST returns other non-2xx (e.g. 500)', async () => {
|
|
921
|
+
globalThis.fetch = createFetchImplementation(async () => new Response('Internal Server Error', {status: 500}))
|
|
922
|
+
|
|
923
|
+
const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
|
|
924
|
+
|
|
925
|
+
expect(result.level).toBe('warning')
|
|
926
|
+
expect(result.summary).toContain('500')
|
|
927
|
+
})
|
|
928
|
+
|
|
929
|
+
it('returns warning (not error) when fetch throws (network/timeout)', async () => {
|
|
930
|
+
globalThis.fetch = createFetchImplementation(async () => {
|
|
931
|
+
throw new Error('The operation was aborted due to timeout')
|
|
932
|
+
})
|
|
933
|
+
|
|
934
|
+
const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
|
|
935
|
+
|
|
936
|
+
expect(result.level).toBe('warning')
|
|
937
|
+
expect(result.summary).toContain('Anthropic probe failed')
|
|
938
|
+
expect(result.summary).toContain('aborted')
|
|
939
|
+
})
|
|
940
|
+
|
|
941
|
+
it('never includes the apiKey in summary or details', async () => {
|
|
942
|
+
const secretKey = 'super-secret-api-key-do-not-leak'
|
|
943
|
+
|
|
944
|
+
globalThis.fetch = createFetchImplementation(async () => new Response('Unauthorized', {status: 401}))
|
|
945
|
+
|
|
946
|
+
const result = await checkProviderAuth('https://cliproxy.example.com', secretKey)
|
|
947
|
+
|
|
948
|
+
expect(result.summary).not.toContain(secretKey)
|
|
949
|
+
const detailsText = (result.details ?? []).join('\n')
|
|
950
|
+
expect(detailsText).not.toContain(secretKey)
|
|
951
|
+
})
|
|
952
|
+
|
|
953
|
+
it('uses the default model claude-sonnet-4-6 when no model override given', async () => {
|
|
954
|
+
let capturedBody = ''
|
|
955
|
+
|
|
956
|
+
globalThis.fetch = createFetchImplementation(async (_url, init) => {
|
|
957
|
+
capturedBody = typeof init?.body === 'string' ? init.body : ''
|
|
958
|
+
return new Response(JSON.stringify({choices: []}), {
|
|
959
|
+
status: 200,
|
|
960
|
+
headers: {'content-type': 'application/json'},
|
|
961
|
+
})
|
|
962
|
+
})
|
|
963
|
+
|
|
964
|
+
await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
|
|
965
|
+
|
|
966
|
+
expect(capturedBody).toContain('claude-sonnet-4-6')
|
|
967
|
+
})
|
|
968
|
+
|
|
969
|
+
it('uses the model override when options.model is provided', async () => {
|
|
970
|
+
let capturedBody = ''
|
|
971
|
+
|
|
972
|
+
globalThis.fetch = createFetchImplementation(async (_url, init) => {
|
|
973
|
+
capturedBody = typeof init?.body === 'string' ? init.body : ''
|
|
974
|
+
return new Response(JSON.stringify({choices: []}), {
|
|
975
|
+
status: 200,
|
|
976
|
+
headers: {'content-type': 'application/json'},
|
|
977
|
+
})
|
|
978
|
+
})
|
|
979
|
+
|
|
980
|
+
await checkProviderAuth('https://cliproxy.example.com', 'test-api-key', {model: 'claude-opus-4-5'})
|
|
981
|
+
|
|
982
|
+
expect(capturedBody).toContain('claude-opus-4-5')
|
|
983
|
+
expect(capturedBody).not.toContain('claude-sonnet-4-6')
|
|
984
|
+
})
|
|
985
|
+
|
|
986
|
+
it('includes verbose details (URL, model, status) when verbose=true', async () => {
|
|
987
|
+
globalThis.fetch = createFetchImplementation(
|
|
988
|
+
async () =>
|
|
989
|
+
new Response(JSON.stringify({choices: []}), {
|
|
990
|
+
status: 200,
|
|
991
|
+
headers: {'content-type': 'application/json'},
|
|
992
|
+
}),
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key', {verbose: true})
|
|
996
|
+
|
|
997
|
+
expect(result.details).toBeDefined()
|
|
998
|
+
const detailsText = (result.details ?? []).join('\n')
|
|
999
|
+
expect(detailsText).toContain('https://cliproxy.example.com')
|
|
1000
|
+
expect(detailsText).toContain('claude-sonnet-4-6')
|
|
1001
|
+
})
|
|
1002
|
+
})
|
|
1003
|
+
|
|
1004
|
+
// ─── cliproxyStatusAction — provider auth wiring ─────────────────────────────
|
|
1005
|
+
|
|
1006
|
+
describe('cliproxyStatusAction — provider auth probe wiring', () => {
|
|
1007
|
+
let originalEnv: Record<string, string | undefined>
|
|
1008
|
+
|
|
1009
|
+
beforeEach(() => {
|
|
1010
|
+
originalEnv = {
|
|
1011
|
+
CLIPROXY_URL: process.env.CLIPROXY_URL,
|
|
1012
|
+
CLIPROXY_MANAGEMENT_KEY: process.env.CLIPROXY_MANAGEMENT_KEY,
|
|
1013
|
+
CLIPROXY_API_KEY: process.env.CLIPROXY_API_KEY,
|
|
1014
|
+
}
|
|
1015
|
+
delete process.env.CLIPROXY_URL
|
|
1016
|
+
delete process.env.CLIPROXY_MANAGEMENT_KEY
|
|
1017
|
+
delete process.env.CLIPROXY_API_KEY
|
|
1018
|
+
})
|
|
1019
|
+
|
|
1020
|
+
afterEach(() => {
|
|
1021
|
+
for (const [k, v] of Object.entries(originalEnv)) {
|
|
1022
|
+
if (v === undefined) {
|
|
1023
|
+
delete process.env[k]
|
|
1024
|
+
} else {
|
|
1025
|
+
process.env[k] = v
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
globalThis.fetch = originalFetch
|
|
1029
|
+
})
|
|
1030
|
+
|
|
1031
|
+
it('shows a warning (not error) when no provider key is available', async () => {
|
|
1032
|
+
globalThis.fetch = createFetchImplementation(async url => {
|
|
1033
|
+
if (url.includes('/healthz')) return new Response('ok', {status: 200})
|
|
1034
|
+
throw new Error(`Unexpected fetch: ${url}`)
|
|
1035
|
+
})
|
|
1036
|
+
|
|
1037
|
+
const {ctx, captured} = createCapturedCtx()
|
|
1038
|
+
await cliproxyStatusAction({url: 'https://cliproxy.example.com'}, ctx)
|
|
1039
|
+
|
|
1040
|
+
const output = [...captured.stdout, ...captured.stderr].join('\n')
|
|
1041
|
+
expect(output).toMatch(/CLIPROXY_API_KEY|skipping upstream provider probe/i)
|
|
1042
|
+
// Must be a warning, not an error — no exit(1) for missing key
|
|
1043
|
+
expect(captured.exit).toBeNull()
|
|
1044
|
+
})
|
|
1045
|
+
|
|
1046
|
+
it('runs the provider auth probe when --api-key is provided', async () => {
|
|
1047
|
+
const fetchedUrls: string[] = []
|
|
1048
|
+
|
|
1049
|
+
globalThis.fetch = createFetchImplementation(async url => {
|
|
1050
|
+
fetchedUrls.push(url)
|
|
1051
|
+
if (url.includes('/healthz')) return new Response('ok', {status: 200})
|
|
1052
|
+
if (url.includes('/v1/chat/completions'))
|
|
1053
|
+
return new Response(JSON.stringify({choices: []}), {
|
|
1054
|
+
status: 200,
|
|
1055
|
+
headers: {'content-type': 'application/json'},
|
|
1056
|
+
})
|
|
1057
|
+
throw new Error(`Unexpected fetch: ${url}`)
|
|
1058
|
+
})
|
|
1059
|
+
|
|
1060
|
+
const {ctx, captured} = createCapturedCtx()
|
|
1061
|
+
await cliproxyStatusAction({url: 'https://cliproxy.example.com', apiKey: 'my-api-key'}, ctx)
|
|
1062
|
+
|
|
1063
|
+
expect(fetchedUrls.some(u => u.includes('/v1/chat/completions'))).toBe(true)
|
|
1064
|
+
const output = [...captured.stdout, ...captured.stderr].join('\n')
|
|
1065
|
+
expect(output).toContain('Upstream provider auth')
|
|
1066
|
+
})
|
|
1067
|
+
|
|
1068
|
+
it('does NOT forward ambient CLIPROXY_API_KEY to an explicit --url override', async () => {
|
|
1069
|
+
process.env.CLIPROXY_API_KEY = 'ambient-api-key-secret'
|
|
1070
|
+
|
|
1071
|
+
const capturedAuthHeaders: string[] = []
|
|
1072
|
+
|
|
1073
|
+
globalThis.fetch = createFetchImplementation(async (url, init) => {
|
|
1074
|
+
const hdrs = init?.headers
|
|
1075
|
+
let auth: string | null = null
|
|
1076
|
+
if (hdrs instanceof Headers) {
|
|
1077
|
+
auth = hdrs.get('authorization')
|
|
1078
|
+
} else if (hdrs !== null && hdrs !== undefined && typeof hdrs === 'object') {
|
|
1079
|
+
const hdrsObj = hdrs as Record<string, string>
|
|
1080
|
+
auth = hdrsObj.authorization ?? hdrsObj.Authorization ?? null
|
|
1081
|
+
}
|
|
1082
|
+
if (auth) capturedAuthHeaders.push(auth)
|
|
1083
|
+
|
|
1084
|
+
if (url.includes('/healthz')) return new Response('ok', {status: 200})
|
|
1085
|
+
if (url.includes('/v1/chat/completions')) return new Response(JSON.stringify({choices: []}), {status: 200})
|
|
1086
|
+
return new Response('ok', {status: 200})
|
|
1087
|
+
})
|
|
1088
|
+
|
|
1089
|
+
const {ctx} = createCapturedCtx()
|
|
1090
|
+
// Explicit --url override to a non-trusted host — ambient key must NOT follow
|
|
1091
|
+
await cliproxyStatusAction({url: 'https://evil.example.com'}, ctx)
|
|
1092
|
+
|
|
1093
|
+
// The ambient API key must not appear in any Authorization header sent to evil host
|
|
1094
|
+
expect(capturedAuthHeaders.some(h => h.includes('ambient-api-key-secret'))).toBe(false)
|
|
1095
|
+
})
|
|
1096
|
+
|
|
1097
|
+
it('uses ambient CLIPROXY_API_KEY when URL is the trusted default', async () => {
|
|
1098
|
+
process.env.CLIPROXY_API_KEY = 'ambient-api-key-trusted'
|
|
1099
|
+
|
|
1100
|
+
const capturedAuthHeaders: string[] = []
|
|
1101
|
+
|
|
1102
|
+
globalThis.fetch = createFetchImplementation(async (url, init) => {
|
|
1103
|
+
const hdrs = init?.headers
|
|
1104
|
+
let auth: string | null = null
|
|
1105
|
+
if (hdrs instanceof Headers) {
|
|
1106
|
+
auth = hdrs.get('authorization')
|
|
1107
|
+
} else if (hdrs !== null && hdrs !== undefined && typeof hdrs === 'object') {
|
|
1108
|
+
const hdrsObj = hdrs as Record<string, string>
|
|
1109
|
+
auth = hdrsObj.authorization ?? hdrsObj.Authorization ?? null
|
|
1110
|
+
}
|
|
1111
|
+
if (auth) capturedAuthHeaders.push(auth)
|
|
1112
|
+
|
|
1113
|
+
if (url.includes('/healthz')) return new Response('ok', {status: 200})
|
|
1114
|
+
if (url.includes('/v1/chat/completions'))
|
|
1115
|
+
return new Response(JSON.stringify({choices: []}), {
|
|
1116
|
+
status: 200,
|
|
1117
|
+
headers: {'content-type': 'application/json'},
|
|
1118
|
+
})
|
|
1119
|
+
// Management endpoints — return ok so management checks don't block
|
|
1120
|
+
if (url.includes('/v0/management/config'))
|
|
1121
|
+
return new Response('{}', {status: 200, headers: {'content-type': 'application/json'}})
|
|
1122
|
+
if (url.includes('/v0/management/usage-queue'))
|
|
1123
|
+
return new Response('[]', {status: 200, headers: {'content-type': 'application/json'}})
|
|
1124
|
+
if (url.includes('/v0/management/latest-version'))
|
|
1125
|
+
return new Response(JSON.stringify({'latest-version': 'v1.0.0'}), {
|
|
1126
|
+
status: 200,
|
|
1127
|
+
headers: {'content-type': 'application/json'},
|
|
1128
|
+
})
|
|
1129
|
+
return new Response('ok', {status: 200})
|
|
1130
|
+
})
|
|
1131
|
+
|
|
1132
|
+
const {ctx} = createCapturedCtx()
|
|
1133
|
+
// Default trusted URL — ambient key SHOULD be used
|
|
1134
|
+
await cliproxyStatusAction({url: 'https://cliproxy.fro.bot'}, ctx)
|
|
1135
|
+
|
|
1136
|
+
expect(capturedAuthHeaders.some(h => h.includes('ambient-api-key-trusted'))).toBe(true)
|
|
1137
|
+
})
|
|
1138
|
+
|
|
1139
|
+
it('provider auth error (401) causes exit(1) — dead upstream is a real error', async () => {
|
|
1140
|
+
globalThis.fetch = createFetchImplementation(async url => {
|
|
1141
|
+
if (url.includes('/healthz')) return new Response('ok', {status: 200})
|
|
1142
|
+
if (url.includes('/v1/chat/completions')) return new Response('Unauthorized', {status: 401})
|
|
1143
|
+
throw new Error(`Unexpected fetch: ${url}`)
|
|
1144
|
+
})
|
|
1145
|
+
|
|
1146
|
+
const {ctx, captured} = createCapturedCtx()
|
|
1147
|
+
let threw: unknown
|
|
1148
|
+
try {
|
|
1149
|
+
await cliproxyStatusAction({url: 'https://cliproxy.example.com', apiKey: 'my-api-key'}, ctx)
|
|
1150
|
+
} catch (error) {
|
|
1151
|
+
threw = error
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
expect(threw).toBeInstanceOf(MockProcessExit)
|
|
1155
|
+
expect(captured.exit?.code).toBe(1)
|
|
1156
|
+
})
|
|
1157
|
+
})
|
|
@@ -226,6 +226,113 @@ export async function checkVersion(baseUrl: string, key: string): Promise<CheckR
|
|
|
226
226
|
}
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
const DEFAULT_PROVIDER_MODEL = 'claude-sonnet-4-6'
|
|
230
|
+
|
|
231
|
+
export interface ProviderAuthOptions {
|
|
232
|
+
model?: string
|
|
233
|
+
verbose?: boolean
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Probe the upstream provider auth by sending a minimal chat completion request.
|
|
238
|
+
* Returns a CheckResult — never throws.
|
|
239
|
+
*
|
|
240
|
+
* - 200 → ok
|
|
241
|
+
* - 401 OR (503 with auth-unavailable body markers) → error with remediation hint
|
|
242
|
+
* - other non-2xx → warning (don't fail status on unrelated upstream issues)
|
|
243
|
+
* - fetch throw (timeout/network) → warning (flaky probe should not fail status)
|
|
244
|
+
*
|
|
245
|
+
* The apiKey is NEVER included in summary or details output.
|
|
246
|
+
*/
|
|
247
|
+
export async function checkProviderAuth(
|
|
248
|
+
baseUrl: string,
|
|
249
|
+
apiKey: string,
|
|
250
|
+
options?: ProviderAuthOptions,
|
|
251
|
+
): Promise<CheckResult> {
|
|
252
|
+
const model = options?.model ?? DEFAULT_PROVIDER_MODEL
|
|
253
|
+
const verbose = options?.verbose === true
|
|
254
|
+
const endpoint = `${baseUrl}/v1/chat/completions`
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
const response = await fetch(endpoint, {
|
|
258
|
+
method: 'POST',
|
|
259
|
+
headers: {
|
|
260
|
+
authorization: `Bearer ${apiKey}`,
|
|
261
|
+
'content-type': 'application/json',
|
|
262
|
+
},
|
|
263
|
+
body: JSON.stringify({
|
|
264
|
+
model,
|
|
265
|
+
messages: [{role: 'user', content: 'ping'}],
|
|
266
|
+
max_tokens: 1,
|
|
267
|
+
}),
|
|
268
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
const details: string[] = []
|
|
272
|
+
if (verbose) {
|
|
273
|
+
details.push(`URL: ${endpoint}`)
|
|
274
|
+
details.push(`Model: ${model}`)
|
|
275
|
+
details.push(`Status: ${response.status}`)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (response.ok) {
|
|
279
|
+
return {
|
|
280
|
+
title: 'Upstream provider auth (anthropic)',
|
|
281
|
+
level: 'ok',
|
|
282
|
+
summary: `anthropic route OK (${model})`,
|
|
283
|
+
details: details.length > 0 ? details : undefined,
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// 401 is always an auth failure
|
|
288
|
+
if (response.status === 401) {
|
|
289
|
+
return {
|
|
290
|
+
title: 'Upstream provider auth (anthropic)',
|
|
291
|
+
level: 'error',
|
|
292
|
+
summary: `Anthropic upstream auth unavailable (${response.status}) — run: cliproxy login claude`,
|
|
293
|
+
details: details.length > 0 ? details : undefined,
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 503 may be auth_unavailable — check body for known markers
|
|
298
|
+
if (response.status === 503) {
|
|
299
|
+
let bodyText = ''
|
|
300
|
+
try {
|
|
301
|
+
bodyText = await response.text()
|
|
302
|
+
} catch {
|
|
303
|
+
// ignore body read failure
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const isAuthUnavailable = /auth_unavailable|no auth available|providers=claude/i.test(bodyText)
|
|
307
|
+
|
|
308
|
+
if (isAuthUnavailable) {
|
|
309
|
+
return {
|
|
310
|
+
title: 'Upstream provider auth (anthropic)',
|
|
311
|
+
level: 'error',
|
|
312
|
+
summary: `Anthropic upstream auth unavailable (${response.status}) — run: cliproxy login claude`,
|
|
313
|
+
details: details.length > 0 ? details : undefined,
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Any other non-2xx: warning (don't fail status on unrelated upstream issues)
|
|
319
|
+
return {
|
|
320
|
+
title: 'Upstream provider auth (anthropic)',
|
|
321
|
+
level: 'warning',
|
|
322
|
+
summary: `Anthropic probe returned HTTP ${response.status}`,
|
|
323
|
+
details: details.length > 0 ? details : undefined,
|
|
324
|
+
}
|
|
325
|
+
} catch (error) {
|
|
326
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
327
|
+
return {
|
|
328
|
+
title: 'Upstream provider auth (anthropic)',
|
|
329
|
+
level: 'warning',
|
|
330
|
+
summary: `Anthropic probe failed: ${message}`,
|
|
331
|
+
details: verbose ? [`URL: ${endpoint}`, `Model: ${model}`] : undefined,
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
229
336
|
/**
|
|
230
337
|
* Probe the management API with a cheap auth check before issuing parallel calls.
|
|
231
338
|
* Returns null on success, or a CheckResult describing the auth failure.
|
|
@@ -377,6 +484,8 @@ export async function getCliproxyStatusSummary(baseUrl: string, key: string, ver
|
|
|
377
484
|
export interface StatusOptions {
|
|
378
485
|
url?: string
|
|
379
486
|
key?: string
|
|
487
|
+
/** Downstream API key (bearer) for the upstream provider-auth probe. Maps from --api-key CLI flag. */
|
|
488
|
+
apiKey?: string
|
|
380
489
|
verbose?: boolean
|
|
381
490
|
}
|
|
382
491
|
|
|
@@ -396,6 +505,10 @@ export async function cliproxyStatusAction(options: StatusOptions, ctx: ActionCt
|
|
|
396
505
|
// An ambient env key is only forwarded when the resolved baseUrl is trusted.
|
|
397
506
|
const managementKey = options.key ?? (urlIsExplicitlyOverridden ? undefined : process.env.CLIPROXY_MANAGEMENT_KEY)
|
|
398
507
|
|
|
508
|
+
// Provider (downstream) API key for the upstream provider-auth probe.
|
|
509
|
+
// Same trusted-URL guard: ambient CLIPROXY_API_KEY only follows to the trusted URL.
|
|
510
|
+
const providerKey = options.apiKey ?? (urlIsExplicitlyOverridden ? undefined : process.env.CLIPROXY_API_KEY)
|
|
511
|
+
|
|
399
512
|
ctx.console.log('CLIProxyAPI status')
|
|
400
513
|
ctx.console.log('')
|
|
401
514
|
|
|
@@ -419,6 +532,17 @@ export async function cliproxyStatusAction(options: StatusOptions, ctx: ActionCt
|
|
|
419
532
|
results.push(mgmt.usage, mgmt.version)
|
|
420
533
|
}
|
|
421
534
|
|
|
535
|
+
// Upstream provider-auth probe — only when a downstream API key is available.
|
|
536
|
+
if (providerKey) {
|
|
537
|
+
results.push(await checkProviderAuth(baseUrl, providerKey, {verbose}))
|
|
538
|
+
} else {
|
|
539
|
+
results.push({
|
|
540
|
+
title: 'Upstream provider auth',
|
|
541
|
+
level: 'warning',
|
|
542
|
+
summary: 'CLIPROXY_API_KEY not set — skipping upstream provider probe.',
|
|
543
|
+
})
|
|
544
|
+
}
|
|
545
|
+
|
|
422
546
|
for (const result of results) {
|
|
423
547
|
printCheckResult(result, ctx)
|
|
424
548
|
ctx.console.log('')
|
|
@@ -460,6 +584,14 @@ export function registerCliproxyStatus(cli: ReturnType<typeof goke>): void {
|
|
|
460
584
|
'--key [key]',
|
|
461
585
|
z.string().describe('Management API key. Falls back to CLIPROXY_MANAGEMENT_KEY when omitted.'),
|
|
462
586
|
)
|
|
587
|
+
.option(
|
|
588
|
+
'--api-key [key]',
|
|
589
|
+
z
|
|
590
|
+
.string()
|
|
591
|
+
.describe(
|
|
592
|
+
'Downstream API key (bearer) for the upstream provider-auth probe. Falls back to CLIPROXY_API_KEY. Skipped when absent.',
|
|
593
|
+
),
|
|
594
|
+
)
|
|
463
595
|
.option('--verbose', 'Enable verbose output for all commands')
|
|
464
596
|
.action(cliproxyStatusAction)
|
|
465
597
|
}
|