Skip to content

Commit bf664b1

Browse files
fix: allow query strings and fragments in image URL regex (#484) (#485)
The supportedUrls['image/*'] regex anchored the extension to the end of the string, so URLs with ?query or #fragment did not match and got downloaded + base64-inlined by the AI SDK layer. Updated both chat and completion models to accept an optional [?#].* suffix after the extension. Strict allowlist behavior (extension required, terminal) is preserved. Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 310ba3d commit bf664b1

4 files changed

Lines changed: 172 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@openrouter/ai-sdk-provider': patch
3+
---
4+
5+
Fix `supportedUrls['image/*']` regex to accept image URLs with query strings or fragments (e.g. `https://cdn.example.com/photo.png?height=200`, `.../photo.webp#frag`). Previously the `$` anchor on the extension caused such URLs to be treated as unsupported, forcing the AI SDK runtime to download and base64-inline them, which bloated conversation history and inflated token usage.
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/**
2+
* Regression test for GitHub issue #484
3+
* https://github.com/OpenRouterTeam/ai-sdk-provider/issues/484
4+
*
5+
* Issue: "Zod Schema for Image URLs restricts query params"
6+
*
7+
* The `supportedUrls` regex for `image/*` only matches URLs that END with a
8+
* known extension. Valid image URLs with query strings or fragments do NOT
9+
* match, so the AI SDK's LanguageModelV3 layer treats them as unsupported
10+
* and downloads + base64-encodes them.
11+
*/
12+
import { describe, expect, it } from 'vitest';
13+
import { createOpenRouter } from '@/src';
14+
15+
const provider = createOpenRouter({ apiKey: 'test-key' });
16+
const chatModel = provider.chat('anthropic/claude-3.5-sonnet');
17+
const completionModel = provider.completion('openai/gpt-3.5-turbo-instruct');
18+
19+
const IMAGE_URLS_WITH_QUERY_PARAMS = [
20+
'https://cdn.example.com/photo.png?height=200',
21+
'https://cdn.example.com/photo.jpeg?w=100&h=200',
22+
'https://cdn.example.com/photo.webp?v=1&t=2',
23+
'https://cdn.example.com/photo.gif?cache=false',
24+
'https://cdn.example.com/photo.jpg?signature=abc123',
25+
] as const;
26+
27+
const IMAGE_URLS_WITH_FRAGMENTS = [
28+
'https://cdn.example.com/photo.png#section',
29+
'https://cdn.example.com/photo.webp#fragment',
30+
] as const;
31+
32+
const IMAGE_URLS_WITH_QUERY_AND_FRAGMENT = [
33+
'https://cdn.example.com/photo.png?height=200#section',
34+
] as const;
35+
36+
const STILL_VALID_PLAIN_URLS = [
37+
'https://cdn.example.com/photo.png',
38+
'https://cdn.example.com/photo.PNG',
39+
'https://cdn.example.com/photo.jpeg',
40+
'https://cdn.example.com/photo.jpg',
41+
'https://cdn.example.com/photo.webp',
42+
'https://cdn.example.com/photo.gif',
43+
'data:image/png;base64,AAECAw==',
44+
] as const;
45+
46+
const STILL_INVALID_URLS = [
47+
'https://example.com/not-an-image.txt',
48+
'ftp://example.com/photo.png',
49+
'https://example.com/document.pdf',
50+
] as const;
51+
52+
/**
53+
* Additional defensive edge cases for the same failure mode.
54+
*
55+
* These cover real-world image URL shapes commonly seen with CDNs and
56+
* pre-signed object storage URLs (S3, GCS, R2, etc.). They are deliberately
57+
* tightly scoped to the same `?...` / `#...` suffix relaxation — they should
58+
* NOT pass under any pattern that drops the extension requirement entirely.
59+
*/
60+
const DEFENSIVE_EXTRA_VALID_URLS = [
61+
// URL-encoded query value (typical for signed URLs)
62+
'https://cdn.example.com/photo.png?token=abc%20def%2F123',
63+
// Pre-signed S3-style URL with multiple query params
64+
'https://my-bucket.s3.amazonaws.com/photo.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=900&X-Amz-Signature=deadbeef',
65+
// Both query and fragment, plus uppercase extension
66+
'https://cdn.example.com/photo.PNG?x=1#frag',
67+
// Empty query string (still a query, just no params)
68+
'https://cdn.example.com/photo.gif?',
69+
// Empty fragment
70+
'https://cdn.example.com/photo.webp#',
71+
] as const;
72+
73+
/**
74+
* Defensive cases that must REMAIN rejected after the fix.
75+
*
76+
* The most important one is the "extension-in-the-path" shape:
77+
* `https://cdn.example.com/some.png/redirect`. Naive relaxations of the
78+
* regex (e.g. dropping the `$` anchor or replacing it with `.*`) would
79+
* accept this, which would regress the explicit `image/*` allowlist.
80+
*/
81+
const DEFENSIVE_EXTRA_INVALID_URLS = [
82+
// Extension is in the path, not terminal — must stay rejected
83+
'https://cdn.example.com/some.png/redirect',
84+
// Extension followed by another path segment
85+
'https://cdn.example.com/photo.jpg/thumbnail',
86+
// No extension at all, just a query string
87+
'https://cdn.example.com/photo?type=png',
88+
// Wrong scheme with query params
89+
'ftp://example.com/photo.png?x=1',
90+
] as const;
91+
92+
function firstImagePattern(supportedUrls: Record<string, RegExp[]>): RegExp[] {
93+
const patterns = supportedUrls['image/*'];
94+
if (!patterns) {
95+
throw new Error('image/* patterns missing');
96+
}
97+
return patterns;
98+
}
99+
100+
function matchesAny(url: string, patterns: RegExp[]): boolean {
101+
return patterns.some((pattern) => pattern.test(url));
102+
}
103+
104+
describe('Issue #484: image URL regex must accept query strings and fragments', () => {
105+
describe('chat model supportedUrls', () => {
106+
const patterns = firstImagePattern(chatModel.supportedUrls);
107+
it.each(IMAGE_URLS_WITH_QUERY_PARAMS)('accepts query params: %s', (url) => {
108+
expect(matchesAny(url, patterns)).toBe(true);
109+
});
110+
it.each(IMAGE_URLS_WITH_FRAGMENTS)('accepts fragment: %s', (url) => {
111+
expect(matchesAny(url, patterns)).toBe(true);
112+
});
113+
it.each(
114+
IMAGE_URLS_WITH_QUERY_AND_FRAGMENT,
115+
)('accepts query+fragment: %s', (url) => {
116+
expect(matchesAny(url, patterns)).toBe(true);
117+
});
118+
it.each(STILL_VALID_PLAIN_URLS)('still accepts plain: %s', (url) => {
119+
expect(matchesAny(url, patterns)).toBe(true);
120+
});
121+
it.each(STILL_INVALID_URLS)('still rejects: %s', (url) => {
122+
expect(matchesAny(url, patterns)).toBe(false);
123+
});
124+
it.each(DEFENSIVE_EXTRA_VALID_URLS)('defensive: accepts %s', (url) => {
125+
expect(matchesAny(url, patterns)).toBe(true);
126+
});
127+
it.each(
128+
DEFENSIVE_EXTRA_INVALID_URLS,
129+
)('defensive: still rejects %s', (url) => {
130+
expect(matchesAny(url, patterns)).toBe(false);
131+
});
132+
});
133+
134+
describe('completion model supportedUrls', () => {
135+
const patterns = firstImagePattern(completionModel.supportedUrls);
136+
it('accepts image URL with query params', () => {
137+
expect(
138+
matchesAny('https://cdn.example.com/photo.png?height=200', patterns),
139+
).toBe(true);
140+
});
141+
it('accepts image URL with fragment', () => {
142+
expect(matchesAny('https://cdn.example.com/photo.webp#f', patterns)).toBe(
143+
true,
144+
);
145+
});
146+
it('still rejects non-image URL', () => {
147+
expect(matchesAny('https://example.com/document.pdf', patterns)).toBe(
148+
false,
149+
);
150+
});
151+
it('defensive: still rejects extension-in-path', () => {
152+
expect(
153+
matchesAny('https://cdn.example.com/some.png/redirect', patterns),
154+
).toBe(false);
155+
});
156+
it('defensive: accepts pre-signed URL with multiple query params', () => {
157+
expect(
158+
matchesAny(
159+
'https://my-bucket.s3.amazonaws.com/photo.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=900&X-Amz-Signature=deadbeef',
160+
patterns,
161+
),
162+
).toBe(true);
163+
});
164+
});
165+
});

src/chat/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export class OpenRouterChatLanguageModel implements LanguageModelV3 {
7070
readonly supportedUrls: Record<string, RegExp[]> = {
7171
'image/*': [
7272
/^data:image\/[a-zA-Z]+;base64,/,
73-
/^https?:\/\/.+\.(jpg|jpeg|png|gif|webp)$/i,
73+
/^https?:\/\/.+\.(jpg|jpeg|png|gif|webp)(?:[?#].*)?$/i,
7474
],
7575
// 'text/*': [/^data:text\//, /^https?:\/\/.+$/],
7676
'application/*': [/^data:application\//, /^https?:\/\/.+$/],

src/completion/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export class OpenRouterCompletionLanguageModel implements LanguageModelV3 {
5454
readonly supportedUrls: Record<string, RegExp[]> = {
5555
'image/*': [
5656
/^data:image\/[a-zA-Z]+;base64,/,
57-
/^https?:\/\/.+\.(jpg|jpeg|png|gif|webp)$/i,
57+
/^https?:\/\/.+\.(jpg|jpeg|png|gif|webp)(?:[?#].*)?$/i,
5858
],
5959
'text/*': [/^data:text\//, /^https?:\/\/.+$/],
6060
'application/*': [/^data:application\//, /^https?:\/\/.+$/],

0 commit comments

Comments
 (0)