Skip to content

Commit c6a325f

Browse files
committed
Merge branch 'main' into fix/gemini-parallel-tools-and-anthropic-thinking
1 parent 4abca40 commit c6a325f

File tree

99 files changed

+2614
-156
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

99 files changed

+2614
-156
lines changed

.changeset/caller-attribution.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"manifest": minor
3+
---
4+
5+
feat: capture caller attribution headers on proxy requests
6+
7+
Every request to `/v1/chat/completions` now has its HTTP headers classified
8+
into a new `caller_attribution` JSON field on the agent message. The
9+
classifier understands the OpenRouter attribution convention
10+
(`HTTP-Referer`, `X-OpenRouter-Title` / `X-Title`, `X-OpenRouter-Categories`)
11+
as well as Stainless-generated SDK fingerprints (`x-stainless-lang`,
12+
package version, runtime / os / arch) used by the official OpenAI and
13+
Anthropic SDKs, plus common raw clients like `curl`, `python-requests`,
14+
`node-fetch` and `axios`. Values are sanitised (control chars stripped,
15+
capped length) and the referer is normalised to its origin.
16+
17+
The data is stored on every successful, failed, and fallback message so
18+
it's available for future analytics surfaces.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"manifest": minor
3+
---
4+
5+
feat: add Ollama Cloud as a subscription provider for cloud-hosted model access
6+
7+
Users can now connect Ollama Cloud (https://ollama.com) alongside local Ollama. The
8+
subscription tab accepts an API key pasted from ollama.com/settings/keys and lists
9+
cloud-hosted models like DeepSeek, Qwen, Gemma, and Llama. Ollama Cloud is registered
10+
as a separate provider from the existing local Ollama integration, so both can be
11+
active on the same agent without conflict.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"manifest": patch
3+
---
4+
5+
Add Z.ai GLM Coding Plan as a subscription provider. Users can now connect their
6+
Z.ai Coding Plan subscription to access GLM-5.1 and other subscription-exclusive
7+
models through the routing system.

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/backend/src/analytics/services/messages-query.service.spec.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ describe('MessagesQueryService', () => {
1919
const mockQb: Record<string, jest.Mock> = {
2020
select: jest.fn(),
2121
addSelect: jest.fn(),
22+
distinct: jest.fn(),
2223
leftJoin: jest.fn(),
2324
where: jest.fn(),
2425
andWhere: jest.fn(),
@@ -35,6 +36,7 @@ describe('MessagesQueryService', () => {
3536
const chainableMethods = [
3637
'select',
3738
'addSelect',
39+
'distinct',
3840
'leftJoin',
3941
'where',
4042
'andWhere',
@@ -698,6 +700,180 @@ describe('MessagesQueryService', () => {
698700

699701
expect(result.providers).toEqual(['anthropic', 'gemini', 'openai']);
700702
});
703+
704+
/**
705+
* These tests cover the new stored-provider path:
706+
* 1. getDistinctModels collects providers from the distinct rows
707+
* 2. deriveProviders merges stored providers with inferred-from-model providers
708+
* 3. getMessages provider filter ORs on at.provider = ? AND legacy model match
709+
*/
710+
describe('stored provider column', () => {
711+
it('derives provider from the stored provider column when present', async () => {
712+
mockGetRawOne.mockResolvedValueOnce({ total: 1 });
713+
mockGetRawMany
714+
.mockResolvedValueOnce([
715+
{
716+
id: 'msg-1',
717+
timestamp: '2026-02-16 10:00:00',
718+
model: 'gemma4:31b',
719+
provider: 'ollama-cloud',
720+
},
721+
])
722+
.mockResolvedValueOnce([{ model: 'gemma4:31b', provider: 'ollama-cloud' }]);
723+
724+
const result = await service.getMessages({
725+
range: '24h',
726+
userId: 'test-user',
727+
limit: 20,
728+
});
729+
730+
// Without the stored provider, inferProviderFromModel would return
731+
// `ollama` for `gemma4:31b` (tagless colon heuristic). The stored value
732+
// takes precedence.
733+
expect(result.providers).toEqual(expect.arrayContaining(['ollama-cloud']));
734+
expect(result.providers).toContain('ollama');
735+
});
736+
737+
it('merges stored providers with providers inferred from legacy rows', async () => {
738+
mockGetRawOne.mockResolvedValueOnce({ total: 2 });
739+
mockGetRawMany
740+
.mockResolvedValueOnce([
741+
{
742+
id: 'msg-1',
743+
timestamp: '2026-02-16 10:00:00',
744+
model: 'deepseek-v3.2',
745+
provider: 'ollama-cloud',
746+
},
747+
{
748+
id: 'msg-2',
749+
timestamp: '2026-02-16 09:00:00',
750+
model: 'gpt-4o',
751+
provider: null,
752+
},
753+
])
754+
.mockResolvedValueOnce([
755+
{ model: 'deepseek-v3.2', provider: 'ollama-cloud' },
756+
{ model: 'gpt-4o', provider: null },
757+
]);
758+
759+
const result = await service.getMessages({
760+
range: '24h',
761+
userId: 'test-user',
762+
limit: 20,
763+
});
764+
765+
// deepseek-v3.2 is stored with ollama-cloud; gpt-4o has no stored
766+
// provider so it falls back to inference → openai.
767+
expect(result.providers.sort()).toEqual(['deepseek', 'ollama-cloud', 'openai']);
768+
});
769+
770+
it('skips null and empty provider values in distinct rows', async () => {
771+
mockGetRawOne.mockResolvedValueOnce({ total: 1 });
772+
mockGetRawMany
773+
.mockResolvedValueOnce([{ id: 'msg-1', timestamp: '2026-02-16 10:00:00', model: 'gpt-4o' }])
774+
// Include rows with null and empty-string provider values to cover
775+
// the `providerValue != null && providerValue !== ''` branch.
776+
.mockResolvedValueOnce([
777+
{ model: 'gpt-4o', provider: null },
778+
{ model: 'claude-opus-4-6', provider: '' },
779+
{ model: 'deepseek-v3.2', provider: 'ollama-cloud' },
780+
]);
781+
782+
const result = await service.getMessages({
783+
range: '24h',
784+
userId: 'test-user',
785+
limit: 20,
786+
});
787+
788+
// The null and '' providers must not create spurious entries; only the
789+
// real ollama-cloud entry plus the model-name-inferred ones.
790+
expect(result.providers).toContain('ollama-cloud');
791+
expect(result.providers).toContain('openai');
792+
expect(result.providers).toContain('anthropic');
793+
expect(result.providers).not.toContain('');
794+
});
795+
796+
it('derives providers skipping null entries in stored list', async () => {
797+
// Cover deriveProviders() line where `if (p) seen.add(p)` guards against
798+
// null values surfacing in the stored providers array.
799+
const derive = (
800+
service as unknown as {
801+
deriveProviders: (m: string[], p: string[]) => string[];
802+
}
803+
).deriveProviders.bind(service);
804+
805+
// Intentionally pass a null inside the array to simulate a row that had
806+
// provider = null. The TtlCache contract uses string[], but the guard
807+
// defends against legacy or corrupted cached entries.
808+
const result = derive(
809+
['gpt-4o'],
810+
['anthropic', null as unknown as string, '', 'ollama-cloud'],
811+
);
812+
expect(result).toEqual(['anthropic', 'ollama-cloud', 'openai']);
813+
});
814+
815+
it('provider filter: ORs stored provider = ? with legacy model IN (...)', async () => {
816+
// getDistinctModels returns a mix of models and providers.
817+
// `matching` will include gpt-4o (inferred as openai), so the OR branch
818+
// with at.provider IS NULL AND at.model IN (...) is built.
819+
mockGetRawMany.mockResolvedValueOnce([
820+
{ model: 'gpt-4o', provider: 'openai' },
821+
{ model: 'gpt-4.1', provider: null },
822+
{ model: 'claude-opus-4-6', provider: null },
823+
]);
824+
mockGetRawOne.mockResolvedValueOnce({ total: 2 });
825+
mockGetRawMany
826+
.mockResolvedValueOnce([
827+
{
828+
id: 'msg-1',
829+
timestamp: '2026-02-16 10:00:00',
830+
model: 'gpt-4o',
831+
provider: 'openai',
832+
},
833+
{
834+
id: 'msg-2',
835+
timestamp: '2026-02-16 09:00:00',
836+
model: 'gpt-4.1',
837+
provider: null,
838+
},
839+
])
840+
.mockResolvedValueOnce([
841+
{ model: 'gpt-4o', provider: 'openai' },
842+
{ model: 'gpt-4.1', provider: null },
843+
{ model: 'claude-opus-4-6', provider: null },
844+
]);
845+
846+
const result = await service.getMessages({
847+
range: '24h',
848+
userId: 'test-user',
849+
limit: 20,
850+
provider: 'openai',
851+
});
852+
853+
expect(result.total_count).toBe(2);
854+
expect(result.items).toHaveLength(2);
855+
});
856+
857+
it('provider filter: uses only the stored provider branch when no legacy models match', async () => {
858+
// All distinct models map to something other than the requested provider
859+
// (e.g. the legacy OR branch would be empty), exercising the matching.length === 0 path.
860+
mockGetRawMany.mockResolvedValueOnce([{ model: 'gpt-4o', provider: 'openai' }]);
861+
mockGetRawOne.mockResolvedValueOnce({ total: 0 });
862+
mockGetRawMany
863+
.mockResolvedValueOnce([])
864+
.mockResolvedValueOnce([{ model: 'gpt-4o', provider: 'openai' }]);
865+
866+
const result = await service.getMessages({
867+
range: '24h',
868+
userId: 'test-user',
869+
limit: 20,
870+
provider: 'anthropic',
871+
});
872+
873+
expect(result.total_count).toBe(0);
874+
expect(result.items).toEqual([]);
875+
});
876+
});
701877
});
702878

703879
describe('MessagesQueryService (sql.js / local mode)', () => {
@@ -714,6 +890,7 @@ describe('MessagesQueryService (sql.js / local mode)', () => {
714890
const mockQb = {
715891
select: jest.fn().mockReturnThis(),
716892
addSelect: mockAddSelect,
893+
distinct: jest.fn().mockReturnThis(),
717894
leftJoin: jest.fn().mockReturnThis(),
718895
where: jest.fn().mockReturnThis(),
719896
andWhere: jest.fn().mockReturnThis(),

0 commit comments

Comments
 (0)