Skip to content

Commit 5a5ce73

Browse files
authored
[sync] feat: sandbox agent T2405 (#1431) (#2812)
Synced from teableio/teable-ee@ea48a27
1 parent 8171ffb commit 5a5ce73

20 files changed

Lines changed: 1937 additions & 44 deletions

File tree

apps/nestjs-backend/src/cache/cache.module.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/* eslint-disable @typescript-eslint/naming-convention */
22
import { ConfigurableModuleBuilder, type DynamicModule, Module } from '@nestjs/common';
33
import { CacheProvider } from './cache.provider';
4+
import { RedisNativeService } from './redis-native.service';
45

56
export interface CacheModuleOptions {
67
global?: boolean;
@@ -10,8 +11,8 @@ export const { ConfigurableModuleClass: CacheModuleClass, OPTIONS_TYPE } =
1011
new ConfigurableModuleBuilder<CacheModuleOptions>().build();
1112

1213
@Module({
13-
providers: [CacheProvider],
14-
exports: [CacheProvider],
14+
providers: [CacheProvider, RedisNativeService],
15+
exports: [CacheProvider, RedisNativeService],
1516
})
1617
export class CacheModule extends CacheModuleClass {
1718
static register(options: typeof OPTIONS_TYPE): DynamicModule {

apps/nestjs-backend/src/cache/cache.service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export class CacheService<T extends ICacheStore = ICacheStore> {
1111
constructor(private readonly cacheManager: Keyv<any>) {}
1212
private readonly logger = new Logger(CacheService.name);
1313

14+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1415
getKeyv(): Keyv<any> {
1516
return this.cacheManager;
1617
}
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import type { Redis } from 'ioredis';
3+
import { CacheService } from './cache.service';
4+
5+
/**
6+
* Type-safe wrapper around the ioredis client obtained from CacheService.
7+
*
8+
* Provides:
9+
* - Normalized return types (e.g. `exists` → boolean, `sismember` → boolean)
10+
* - Defensive guards (empty array protection for variadic commands)
11+
* - Consistent error when Redis is unavailable
12+
*/
13+
@Injectable()
14+
export class RedisNativeService {
15+
private readonly logger = new Logger(RedisNativeService.name);
16+
private readonly redis: Redis | undefined;
17+
18+
constructor(cacheService: CacheService) {
19+
try {
20+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
21+
const store = cacheService.getKeyv().opts?.store as any;
22+
this.redis = store?.redis || store?.client;
23+
} catch {
24+
this.redis = undefined;
25+
}
26+
if (!this.redis) {
27+
this.logger.warn('Redis client not available — RedisNativeService disabled');
28+
}
29+
}
30+
31+
private get client(): Redis {
32+
if (!this.redis) {
33+
throw new Error('RedisNativeService: Redis is not available (cache provider is not redis)');
34+
}
35+
return this.redis;
36+
}
37+
38+
/**
39+
* Get the value of a string key.
40+
* @param key - Redis key
41+
* @returns Value string, or null if key doesn't exist
42+
*/
43+
async get(key: string): Promise<string | null> {
44+
return this.client.get(key);
45+
}
46+
47+
/**
48+
* Set multiple fields on a hash key atomically. No-op if fields is empty.
49+
* @param key - Redis hash key
50+
* @param fields - Key-value pairs to set
51+
*/
52+
async hset(key: string, fields: Record<string, string>): Promise<void> {
53+
const entries = Object.entries(fields).flat();
54+
if (entries.length > 0) {
55+
await this.client.hset(key, ...entries);
56+
}
57+
}
58+
59+
/**
60+
* Get all fields and values of a hash.
61+
* @param key - Redis hash key
62+
* @returns All field-value pairs, or null if key doesn't exist
63+
*/
64+
async hgetall(key: string): Promise<Record<string, string> | null> {
65+
const result = await this.client.hgetall(key);
66+
return Object.keys(result).length > 0 ? result : null;
67+
}
68+
69+
/**
70+
* Get a single field value from a hash.
71+
* @param key - Redis hash key
72+
* @param field - Field name within the hash
73+
* @returns Field value, or null if field or key doesn't exist
74+
*/
75+
async hget(key: string, field: string): Promise<string | null> {
76+
return this.client.hget(key, field);
77+
}
78+
79+
/**
80+
* Delete one or more fields from a hash. No-op if fields list is empty.
81+
* @param key - Redis hash key
82+
* @param fields - Field names to delete
83+
*/
84+
async hdel(key: string, ...fields: string[]): Promise<void> {
85+
if (fields.length > 0) {
86+
await this.client.hdel(key, ...fields);
87+
}
88+
}
89+
90+
/**
91+
* Set a TTL (time-to-live) on an existing key.
92+
* @param key - Redis key
93+
* @param seconds - TTL in seconds
94+
*/
95+
async expire(key: string, seconds: number): Promise<void> {
96+
await this.client.expire(key, seconds);
97+
}
98+
99+
/**
100+
* Delete a key.
101+
* @param key - Redis key to delete
102+
*/
103+
async del(key: string): Promise<void> {
104+
await this.client.del(key);
105+
}
106+
107+
/**
108+
* Check if a key exists.
109+
* @param key - Redis key
110+
* @returns true if the key exists, false otherwise
111+
*/
112+
async exists(key: string): Promise<boolean> {
113+
const result = await this.client.exists(key);
114+
return result === 1;
115+
}
116+
117+
/**
118+
* Set a key with a value and TTL (SETEX command).
119+
* @param key - Redis key
120+
* @param seconds - TTL in seconds
121+
* @param value - Value to store
122+
*/
123+
async setex(key: string, seconds: number, value: string): Promise<void> {
124+
await this.client.setex(key, seconds, value);
125+
}
126+
127+
/**
128+
* Atomic set-if-not-exists with TTL (SET key value NX EX seconds).
129+
* @param key - Redis key
130+
* @param seconds - TTL in seconds
131+
* @param value - Value to store
132+
* @returns true if the key was set (didn't exist), false if it already existed
133+
*/
134+
async setnxex(key: string, seconds: number, value: string): Promise<boolean> {
135+
const result = await this.client.set(key, value, 'EX', seconds, 'NX');
136+
return result === 'OK';
137+
}
138+
139+
/**
140+
* Add a member with a score to a sorted set.
141+
* @param key - Redis sorted set key
142+
* @param score - Score for ordering
143+
* @param member - Member value
144+
*/
145+
async zadd(key: string, score: number, member: string): Promise<void> {
146+
await this.client.zadd(key, score, member);
147+
}
148+
149+
/**
150+
* Get all members with scores in the given range (inclusive).
151+
* @param key - Redis sorted set key
152+
* @param min - Minimum score (number or '-inf')
153+
* @param max - Maximum score (number or '+inf')
154+
* @returns Array of member values within the score range
155+
*/
156+
async zrangebyscore(key: string, min: number | string, max: number | string): Promise<string[]> {
157+
return this.client.zrangebyscore(key, min, max);
158+
}
159+
160+
/**
161+
* Remove one or more members from a sorted set. No-op if members list is empty.
162+
* @param key - Redis sorted set key
163+
* @param members - Members to remove
164+
*/
165+
async zrem(key: string, ...members: string[]): Promise<void> {
166+
if (members.length > 0) {
167+
await this.client.zrem(key, ...members);
168+
}
169+
}
170+
171+
/**
172+
* Add one or more members to a set. No-op if members list is empty.
173+
* @param key - Redis set key
174+
* @param members - Members to add
175+
* @returns Number of new members actually added (excludes already-existing)
176+
*/
177+
async sadd(key: string, ...members: string[]): Promise<number> {
178+
if (members.length === 0) return 0;
179+
return this.client.sadd(key, ...members);
180+
}
181+
182+
/**
183+
* Remove one or more members from a set. No-op if members list is empty.
184+
* @param key - Redis set key
185+
* @param members - Members to remove
186+
* @returns Number of members actually removed
187+
*/
188+
async srem(key: string, ...members: string[]): Promise<number> {
189+
if (members.length === 0) return 0;
190+
return this.client.srem(key, ...members);
191+
}
192+
193+
/**
194+
* Check if a member exists in a set.
195+
* @param key - Redis set key
196+
* @param member - Member to check
197+
* @returns true if the member exists in the set, false otherwise
198+
*/
199+
async sismember(key: string, member: string): Promise<boolean> {
200+
const result = await this.client.sismember(key, member);
201+
return result === 1;
202+
}
203+
204+
/**
205+
* Get the number of members in a set (cardinality).
206+
* @param key - Redis set key
207+
* @returns Number of members in the set
208+
*/
209+
async scard(key: string): Promise<number> {
210+
return this.client.scard(key);
211+
}
212+
213+
/**
214+
* Execute a Lua script atomically on the Redis server.
215+
* @param script - Lua script source code
216+
* @param keys - KEYS array accessible in Lua as KEYS[1], KEYS[2], ...
217+
* @param args - ARGV array accessible in Lua as ARGV[1], ARGV[2], ...
218+
* @returns Script return value (type depends on the Lua script)
219+
*/
220+
async eval(script: string, keys: string[], args: (string | number)[]): Promise<unknown> {
221+
return this.client.eval(script, keys.length, ...keys, ...args);
222+
}
223+
224+
/**
225+
* Execute multiple commands in a single network roundtrip (pipeline).
226+
* @param commands - Array of operations, each with an op type, key, and optional args
227+
*/
228+
async pipeline(
229+
commands: Array<{ op: 'del' | 'zrem' | 'srem'; key: string; args?: string[] }>
230+
): Promise<void> {
231+
const pipe = this.client.pipeline();
232+
for (const cmd of commands) {
233+
switch (cmd.op) {
234+
case 'del':
235+
pipe.del(cmd.key);
236+
break;
237+
case 'zrem':
238+
if (cmd.args && cmd.args.length > 0) {
239+
pipe.zrem(cmd.key, ...cmd.args);
240+
}
241+
break;
242+
case 'srem':
243+
if (cmd.args && cmd.args.length > 0) {
244+
pipe.srem(cmd.key, ...cmd.args);
245+
}
246+
break;
247+
}
248+
}
249+
await pipe.exec();
250+
}
251+
252+
/**
253+
* Batch HGETALL via pipeline — single network roundtrip for multiple hash keys.
254+
* @param keys - Array of Redis hash keys
255+
* @returns Array of field-value maps (null for missing/empty hashes)
256+
*/
257+
async hgetallMulti(keys: string[]): Promise<Array<Record<string, string> | null>> {
258+
if (keys.length === 0) return [];
259+
const pipe = this.client.pipeline();
260+
for (const key of keys) {
261+
pipe.hgetall(key);
262+
}
263+
const replies = await pipe.exec();
264+
return keys.map((_, i) => {
265+
const [err, raw] = replies?.[i] ?? [null, null];
266+
if (err || !raw || typeof raw !== 'object' || Object.keys(raw as object).length === 0) {
267+
return null;
268+
}
269+
return raw as Record<string, string>;
270+
});
271+
}
272+
273+
/**
274+
* Batch SCARD via pipeline — single network roundtrip for multiple set keys.
275+
* @param keys - Array of Redis set keys
276+
* @returns Array of cardinalities (0 for missing keys or errors)
277+
*/
278+
async scardMulti(keys: string[]): Promise<number[]> {
279+
if (keys.length === 0) return [];
280+
const pipe = this.client.pipeline();
281+
for (const key of keys) {
282+
pipe.scard(key);
283+
}
284+
const replies = await pipe.exec();
285+
return keys.map((_, i) => {
286+
const [err, count] = replies?.[i] ?? [null, 0];
287+
return err ? 0 : (count as number) ?? 0;
288+
});
289+
}
290+
291+
/**
292+
* Batch EXISTS via pipeline — single network roundtrip for multiple keys.
293+
* @param keys - Array of Redis keys
294+
* @returns Array of booleans (true if key exists)
295+
*/
296+
async existsMulti(keys: string[]): Promise<boolean[]> {
297+
if (keys.length === 0) return [];
298+
const pipe = this.client.pipeline();
299+
for (const key of keys) {
300+
pipe.exists(key);
301+
}
302+
const replies = await pipe.exec();
303+
return keys.map((_, i) => {
304+
const [err, result] = replies?.[i] ?? [null, 0];
305+
return err ? false : (result as number) === 1;
306+
});
307+
}
308+
}

apps/nestjs-backend/src/features/ai/ai.service.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -662,7 +662,6 @@ export class AiService {
662662
const { aiConfig } = await this.settingService.getSetting([SettingKey.AI_CONFIG]);
663663
const gatewayModels = aiConfig?.gatewayModels ?? [];
664664
const localModel = gatewayModels.find((m) => m.id === modelId);
665-
666665
if (localModel?.pricing) {
667666
// Normalize handles both camelCase (admin UI) and snake_case (legacy stored data)
668667
const pricing = normalizeGatewayPricing(localModel.pricing);
@@ -697,7 +696,14 @@ export class AiService {
697696
*/
698697
private async getGatewayApiModel(modelId: string): Promise<IGatewayApiModel | undefined> {
699698
const models = await this.fetchGatewayModelsFromApi();
700-
return models.find((m) => m.id === modelId);
699+
return models.find((m) => {
700+
const modelIdParts = modelId.split('/');
701+
const normalizedModelId = modelIdParts[modelIdParts.length - 1]
702+
.replaceAll('.', '')
703+
.replaceAll('-', '');
704+
const normalizedGatewayModelId = m.id.replaceAll('.', '').replaceAll('-', '').split('/')?.[1];
705+
return normalizedGatewayModelId?.toLowerCase() === normalizedModelId?.toLowerCase();
706+
});
701707
}
702708

703709
/**

0 commit comments

Comments
 (0)